diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 887d75e6d..c8f11ef2f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1674,6 +1674,8 @@ dependencies = [ "gateway-core", "heck", "hex", + "prettyplease", + "quote", "rand_core 0.6.4", "secrecy", "semver", @@ -1681,6 +1683,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "subtle", + "syn 2.0.119", "tempfile", "thiserror", "tokio", @@ -1703,6 +1706,9 @@ dependencies = [ "gateway-core", "gateway-protocol", "headers", + "http", + "http-body", + "http-body-util", "secrecy", "serde", "serde_json", @@ -1728,6 +1734,9 @@ dependencies = [ "chrono-tz", "futures", "futures-timer", + "http", + "http-body", + "http-body-util", "semver", "serde", "serde_json", @@ -1801,6 +1810,9 @@ dependencies = [ "gateway-protocol", "gateway-store", "hex", + "http", + "http-body", + "http-body-util", "image", "jsonschema", "roxmltree", @@ -3147,6 +3159,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "primefield" version = "0.14.0" diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 585955725..30dc5cf7a 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -202,6 +202,23 @@ async fn launch( plugin_runtime, plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup) ); + let settings = gateway_admin::initialize_settings( + store.admin_ports().settings(), + core.snapshot_control(), + admin_providers, + std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing), + ); + let mut services = gateway_admin::service::Registry::new({ + let snapshots = core.snapshots(); + let middleware = plugin_runtime.middleware_registry(); + std::sync::Arc::new(move || { + let snapshot = snapshots.snapshot_for_diagnostics()?; + middleware.resolve(snapshot.extensions()?) + }) + }); + or_shutdown!(plugin_runtime, services.register_settings(&settings)); + let services = std::sync::Arc::new(services); + or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services)); let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await); or_shutdown!(plugin_runtime, store.start_command_line_writes()); let result = commands @@ -277,6 +294,14 @@ async fn launch( client, store.admin_ports(), gateway_admin::AdminRuntimePorts { + service_middleware: { + let snapshots = core.snapshots(); + let middleware = plugin_runtime.middleware_registry(); + std::sync::Arc::new(move || { + let snapshot = snapshots.snapshot_for_diagnostics()?; + middleware.resolve(snapshot.extensions()?) + }) + }, providers: admin_providers, plugin_preparation: plugin_runtime.clone(), plugin_management: plugin_runtime.clone(), @@ -330,9 +355,28 @@ async fn launch( host.worker_health(), host.connection_lifecycle(), ) - ); + ) + .with_middleware({ + let snapshots = core.snapshots(); + let middleware = plugin_runtime.middleware_registry(); + move |snapshot| { + if let Some(snapshot) = snapshot { + return middleware.resolve(snapshot.extensions()?); + } + // 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。 + let diagnostic = snapshots.snapshot_for_diagnostics()?; + middleware.resolve(diagnostic.extensions()?) + } + }); + let http_dispatcher = api.dispatcher(); + or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher)); host.report_startup_ready("API"); + or_shutdown!( + plugin_runtime, + plugin_runtime.bind_services(&admin.services().public_services()) + ); + let mut plan = store.take_worker_contributions(); plan.extend(core.take_worker_contributions()); plan.extend(openai.take_worker_contributions()); diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index c8c667b49..c5cca2540 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -76,7 +76,12 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() { "account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]), "concurrency.rs" => Some(&["error"]), "account/store.rs" => Some(&["account", "error", "identity", "validation"]), - path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]), + path if path.starts_with("policy/") => { + Some(&["account", "identity", "policy", "validation"]) + } + "settings/values.rs" | "settings/compiled.rs" => { + Some(&["account", "concurrency", "identity", "metering", "policy"]) + } path if path.starts_with("account/") => Some(&["account", "identity", "validation"]), _ => None, }; diff --git a/backend/apps/plugin-cli/README.md b/backend/apps/plugin-cli/README.md index 58b3fee09..867e21adb 100644 --- a/backend/apps/plugin-cli/README.md +++ b/backend/apps/plugin-cli/README.md @@ -28,7 +28,8 @@ cpr-plugin package \ 资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖 -作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 的 `request`/`attempt` 仍须显式选择。 +作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 必须显式声明版本 `3`, +并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。 CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的 `plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。 完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md) diff --git a/backend/apps/plugin-cli/tests/main.rs b/backend/apps/plugin-cli/tests/main.rs index 6f452b333..19f94cf6e 100644 --- a/backend/apps/plugin-cli/tests/main.rs +++ b/backend/apps/plugin-cli/tests/main.rs @@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) { fn source_manifest() -> &'static [u8] { br#"{ - "manifestVersion":1, + "manifestVersion":2, "name":"request-workbench", "displayName":"Request Workbench", "publisher":"codex-proxy", @@ -248,10 +248,10 @@ fn source_manifest() -> &'static [u8] { "main":"bin/plugin", "runtime":"trustedProcess", "contributes":{ - "middleware":{"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]}, + "middleware":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]}, "management":{} }, - "permissions":["requests","accounts"], + "icon":"assets/icon.png", "resources":{"LICENSE":"text/plain","assets/icon.png":"image/png","web/index.html":"text/html","web/app.js":"text/javascript","web/app.css":"text/css"} }"# diff --git a/backend/crates/gateway-admin/Cargo.toml b/backend/crates/gateway-admin/Cargo.toml index 43de20175..6a62f26ab 100644 --- a/backend/crates/gateway-admin/Cargo.toml +++ b/backend/crates/gateway-admin/Cargo.toml @@ -36,6 +36,9 @@ chrono-tz = "0.10" tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] } [dev-dependencies] +prettyplease = "0.2" +quote = "1" +syn = { version = "2", features = ["full", "visit-mut"] } tempfile = "3" tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] } diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 85f195187..9d48315aa 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -20,6 +20,7 @@ pub mod backup; pub mod freeze_recovery; pub mod model; pub mod ports; +pub mod service; mod use_case; pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService}; @@ -186,6 +187,7 @@ pub enum AdminConfigError { /// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。 #[derive(Clone)] pub struct AdminServices { + public_services: Arc, plugins: Arc, plugin_management: Arc, proxies: Arc, @@ -205,6 +207,10 @@ pub struct AdminServices { } impl AdminServices { + pub fn public_services(&self) -> Arc { + self.public_services.clone() + } + #[must_use] pub fn plugin_management(&self) -> &PluginManagementService { &self.plugin_management @@ -267,6 +273,10 @@ impl AdminServices { } #[must_use] + pub fn settings_handle(&self) -> Arc { + self.settings.clone() + } + pub fn settings(&self) -> &dyn SettingsService { self.settings.as_ref() } @@ -313,6 +323,7 @@ impl AdminBundle { /// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。 pub struct AdminRuntimePorts { + pub service_middleware: service::PlanSource, pub plugin_preparation: Arc, pub plugin_management: Arc, pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle, @@ -361,6 +372,7 @@ async fn initialize_inner( plugin_accounts: Option>, ) -> Result { let AdminRuntimePorts { + service_middleware, plugin_preparation, plugin_management, published_snapshot, @@ -435,7 +447,16 @@ async fn initialize_inner( }); let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone()); let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone()); + let settings = initialize_settings( + store.settings(), + snapshot.clone(), + registry.clone(), + pricing_source, + ); + let mut public_services = service::Registry::new(service_middleware); + public_services.register_settings(&settings)?; let services = AdminServices { + public_services: Arc::new(public_services), plugin_management: Arc::new(PluginManagementService::new( plugin_management, store.plugins(), @@ -476,12 +497,7 @@ async fn initialize_inner( store.settings(), registry.clone(), )), - settings: Arc::new(DefaultSettingsService::new( - store.settings(), - snapshot.clone(), - registry, - pricing_source, - )), + settings, system, credentials, plugin_accounts, @@ -597,3 +613,18 @@ fn freeze_recovery_worker_contribution( .map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?; Ok(vec![WorkerContribution::Registration(registration)]) } + +/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。 +pub fn initialize_settings( + store: Arc, + snapshot: Arc, + providers: ProviderAdminRegistry, + pricing_source: Arc, +) -> Arc { + Arc::new(DefaultSettingsService::new( + store, + snapshot, + providers, + pricing_source, + )) +} diff --git a/backend/crates/gateway-admin/src/model/auth.rs b/backend/crates/gateway-admin/src/model/auth.rs index 0ef5af021..f5a50d655 100644 --- a/backend/crates/gateway-admin/src/model/auth.rs +++ b/backend/crates/gateway-admin/src/model/auth.rs @@ -14,8 +14,14 @@ pub fn admin_session_actor_ref(admin_user_id: &str) -> String { /// 已认证的管理主体。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum AdminPrincipal { - Session { admin_user_id: String }, + Session { + admin_user_id: String, + }, ApiKey, + /// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。 + Plugin { + instance_id: String, + }, } /// 传给管理用例的安全请求上下文。 @@ -33,6 +39,7 @@ impl AdminRequestContext { admin_user_id: admin_user_id.clone(), }, AdminPrincipal::ApiKey => MutationActor::AdminApiKey, + AdminPrincipal::Plugin { .. } => MutationActor::System, }; MutationContext { actor, diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index bbefdba2c..3f2ad1f11 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -107,7 +107,10 @@ impl AdminError { } /// PostgreSQL 中所有正整数 revision 的管理层表示。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] +#[serde(transparent)] pub struct Revision(NonZeroU64); impl Revision { @@ -156,7 +159,8 @@ impl PageSize { } /// 可审计管理写操作的发起者。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum MutationActor { AdminSession { admin_user_id: String }, AdminApiKey, @@ -164,7 +168,8 @@ pub enum MutationActor { } /// 管理写操作必须携带的审计上下文。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] pub struct MutationContext { pub actor: MutationActor, pub request_id: String, diff --git a/backend/crates/gateway-admin/src/model/plugins/instances.rs b/backend/crates/gateway-admin/src/model/plugins/instances.rs index 9d359b466..f22635869 100644 --- a/backend/crates/gateway-admin/src/model/plugins/instances.rs +++ b/backend/crates/gateway-admin/src/model/plugins/instances.rs @@ -5,12 +5,6 @@ use serde::{Deserialize, Serialize}; use crate::model::Revision; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct PluginPermissionGrant { - pub permission: String, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginFailurePolicy { @@ -26,6 +20,9 @@ pub struct PluginCapabilityBinding { pub stage: String, pub order: i32, pub failure_policy: PluginFailurePolicy, + /// 观察绑定选择事件类型;其他能力不设置事件。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option, #[serde(default)] pub client_key_ids: Vec, #[serde(default)] @@ -66,7 +63,6 @@ pub struct PluginInstance { pub trusted_process: bool, pub configuration: serde_json::Value, pub secrets: BTreeMap, - pub grants: Vec, pub bindings: Vec, pub revision: Revision, } diff --git a/backend/crates/gateway-admin/src/model/plugins/management.rs b/backend/crates/gateway-admin/src/model/plugins/management.rs index 6bc9d155d..2e8da201f 100644 --- a/backend/crates/gateway-admin/src/model/plugins/management.rs +++ b/backend/crates/gateway-admin/src/model/plugins/management.rs @@ -78,6 +78,7 @@ pub struct PluginManagementRequest { pub path: String, pub query: String, pub content_type: Option, + pub headers: Vec, pub body: Vec, pub request_id: String, } @@ -85,5 +86,6 @@ pub struct PluginManagementRequest { pub struct PluginManagementResponse { pub status: u16, pub content_type: String, + pub headers: Vec, pub body: Arc<[u8]>, } diff --git a/backend/crates/gateway-admin/src/model/plugins/mod.rs b/backend/crates/gateway-admin/src/model/plugins/mod.rs index 13a0401b8..204f61480 100644 --- a/backend/crates/gateway-admin/src/model/plugins/mod.rs +++ b/backend/crates/gateway-admin/src/model/plugins/mod.rs @@ -29,19 +29,17 @@ pub struct PluginHostCompatibility { pub manifest_schema_versions: Vec, pub protocol_versions: Vec, pub capabilities: Vec, - pub permissions: Vec, } impl PluginHostCompatibility { /// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。 #[must_use] pub fn is_valid(&self) -> bool { - if self.schema_version != 1 + if self.schema_version != 2 || !valid_versions(&self.manifest_schema_versions) || !valid_versions(&self.protocol_versions) || self.capabilities.is_empty() || self.capabilities.len() > 64 - || self.permissions.len() > 64 { return false; } @@ -53,10 +51,7 @@ impl PluginHostCompatibility { }) { return false; } - let mut permissions = BTreeSet::new(); - !self.permissions.iter().any(|permission| { - !valid_identifier(permission) || !permissions.insert(permission.as_str()) - }) + true } #[must_use] @@ -65,11 +60,6 @@ impl PluginHostCompatibility { .iter() .any(|entry| entry.capability == capability && entry.versions.contains(&version)) } - - #[must_use] - pub fn supports_permission(&self, permission: &str) -> bool { - self.permissions.iter().any(|entry| entry == permission) - } } fn valid_versions(versions: &[u32]) -> bool { @@ -118,7 +108,6 @@ pub struct PluginCompatibilityRequirements { pub manifest_schema_version: u32, pub protocol_version: u32, pub capabilities: Vec<(String, u32)>, - pub permissions: Vec, } /// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。 @@ -177,12 +166,15 @@ impl PluginSource { /// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct PluginContribution { pub id: String, pub version: u32, + #[serde(default)] pub stages: Vec, + #[serde(default)] pub input_formats: Vec, + #[serde(default)] pub output_formats: Vec, } @@ -195,7 +187,6 @@ pub enum PluginArtifactIcon { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct PluginArtifactIconVariants { pub light: String, pub dark: String, @@ -214,17 +205,10 @@ pub struct PluginArtifactIconResource { pub body: Vec, } -/// 管理端使用的静态描述,不承载 SDK 的运行时消息或宿主实现类型。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct PluginPermissionDescription { - pub permission: String, - pub label: String, - pub description: String, -} - +/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。 +/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct PluginArtifactMetadata { pub plugin_id: String, pub version: String, @@ -239,13 +223,18 @@ pub struct PluginArtifactMetadata { #[serde(default)] pub icon: Option, pub contributes: BTreeMap, - pub requested_permissions: Vec, + #[serde(default = "empty_configuration_schema")] pub configuration_schema: serde_json::Value, + #[serde(default)] pub secret_fields: Vec, #[serde(default)] pub state_namespaces: Vec, } +fn empty_configuration_schema() -> serde_json::Value { + serde_json::json!({}) +} + /// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。 #[derive(Clone)] pub struct InspectedPluginArtifact { diff --git a/backend/crates/gateway-admin/src/model/plugins/state.rs b/backend/crates/gateway-admin/src/model/plugins/state.rs index 08dcce3e6..07dd43115 100644 --- a/backend/crates/gateway-admin/src/model/plugins/state.rs +++ b/backend/crates/gateway-admin/src/model/plugins/state.rs @@ -6,7 +6,7 @@ use crate::model::Revision; /// 已由 Runtime 校验的命名空间 schema 与配额事实。 #[derive(Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct PluginStateSchema { pub namespace: String, pub schema_version: u32, diff --git a/backend/crates/gateway-admin/src/model/pricing.rs b/backend/crates/gateway-admin/src/model/pricing.rs index 0101fd528..2a83b4e20 100644 --- a/backend/crates/gateway-admin/src/model/pricing.rs +++ b/backend/crates/gateway-admin/src/model/pricing.rs @@ -3,7 +3,7 @@ use gateway_core::metering::{ModelPriceOverride, PricingOverrides}; use std::collections::{BTreeMap, BTreeSet}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct PricingCatalog { pub defaults: PricingOverrides, pub overrides: PricingOverrides, @@ -27,6 +27,7 @@ pub struct PricingSyncPreview { #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(serde::Serialize)] pub struct SyncPricing { pub preview: PricingSyncPreview, pub models: BTreeMap>, @@ -34,7 +35,7 @@ pub struct SyncPricing { pub type PricingSyncChanges = BTreeMap>>; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum PricingChange { Replace(ModelPriceOverride), Multiplier(u32), @@ -42,7 +43,7 @@ pub enum PricingChange { Delete, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct UpdatePricing { pub provider: String, pub models: Vec, diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 6f002cded..caa73bfbd 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -23,7 +23,8 @@ pub type ProviderRequestProfileUpdates = pub use gateway_core::account::RotationStrategy; /// 完整运行设置事实。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct RuntimeSettings { pub request_profiles: ProviderRequestProfiles, pub config_revision: Revision, @@ -59,8 +60,11 @@ pub struct RuntimeSettings { } /// 原子替换运行设置的命令。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct ReplaceRuntimeSettings { + /// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。 + pub expected_revision: Revision, /// 只覆盖提交的 Provider;未提交项保留当前持久值。 pub request_profile_updates: ProviderRequestProfileUpdates, pub request_location_enabled: bool, @@ -94,7 +98,8 @@ pub struct ReplaceRuntimeSettings { } /// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。 -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] pub struct AdminApiKey(String); impl AdminApiKey { @@ -122,13 +127,16 @@ impl fmt::Debug for AdminApiKey { } /// 管理员 API Key 更新结果。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct AdminApiKeyMutation { pub config_revision: Revision, pub exists: bool, } /// 新管理员 API Key 的一次性返回结果。 +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct RegeneratedAdminApiKey { pub mutation: AdminApiKeyMutation, pub key: AdminApiKey, @@ -154,3 +162,45 @@ impl RuntimeSettings { .and_then(|provider| self.request_profiles.get(&provider)) } } + +impl From for ReplaceRuntimeSettings { + fn from(settings: RuntimeSettings) -> Self { + Self { + expected_revision: settings.config_revision, + request_profile_updates: settings + .request_profiles + .into_iter() + .map(|(provider, value)| (provider, Some(value))) + .collect(), + request_location_enabled: settings.request_location_enabled, + request_location: settings.request_location, + model_mappings: settings.model_mappings, + refresh_margin_seconds: settings.refresh_margin_seconds, + refresh_concurrency: settings.refresh_concurrency, + max_concurrent_per_account: settings.max_concurrent_per_account, + request_interval_ms: settings.request_interval_ms, + max_waiting_per_key: settings.max_waiting_per_key, + max_waiting_per_account: settings.max_waiting_per_account, + concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds, + responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes, + smart_scheduling: settings.smart_scheduling, + rotation_strategy: settings.rotation_strategy, + min_codex_desktop_version: settings.min_codex_desktop_version, + min_codex_cli_version: settings.min_codex_cli_version, + usage_retention_days: settings.usage_retention_days, + ops_event_retention_days: settings.ops_event_retention_days, + audit_retention_days: settings.audit_retention_days, + account_auto_freeze_enabled: settings.account_auto_freeze_enabled, + account_auto_freeze_threshold: settings.account_auto_freeze_threshold, + account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds, + account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds, + account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled, + account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model, + account_auto_freeze_adaptive_concurrency: settings + .account_auto_freeze_adaptive_concurrency, + account_warmup_enabled: settings.account_warmup_enabled, + account_warmup_schedule_time: settings.account_warmup_schedule_time, + account_warmup_model: settings.account_warmup_model, + } + } +} diff --git a/backend/crates/gateway-admin/src/ports/plugin_management.rs b/backend/crates/gateway-admin/src/ports/plugin_management.rs index 7bb5d4034..7a40c9155 100644 --- a/backend/crates/gateway-admin/src/ports/plugin_management.rs +++ b/backend/crates/gateway-admin/src/ports/plugin_management.rs @@ -12,8 +12,8 @@ use crate::model::{ /// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。 #[async_trait] pub trait PluginManagement: Send + Sync { - /// 复核固定发布代次中的模型访问域;调用方已校验持久实例仍为当前目标。 - async fn authorize_models( + /// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本。 + async fn validate_target( &self, published: &ExtensionSetReference, target: &PluginManagementTarget, diff --git a/backend/crates/gateway-admin/src/ports/plugins.rs b/backend/crates/gateway-admin/src/ports/plugins.rs index dab2495f3..f2c0c673c 100644 --- a/backend/crates/gateway-admin/src/ports/plugins.rs +++ b/backend/crates/gateway-admin/src/ports/plugins.rs @@ -176,23 +176,6 @@ pub trait PluginDistribution: Send + Sync { /// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。 #[async_trait] pub trait PluginPackageInspector: Send + Sync { - /// 当前宿主对权限标识的说明,供展示投影使用,不属于不可变制品元数据。 - fn permission_descriptions( - &self, - permissions: &[String], - ) -> Vec { - permissions - .iter() - .map( - |permission| crate::model::plugins::PluginPermissionDescription { - permission: permission.clone(), - label: permission.clone(), - description: String::new(), - }, - ) - .collect() - } - async fn inspect( &self, archive: Arc<[u8]>, @@ -314,7 +297,7 @@ pub trait PluginStore: Send + Sync { source: PluginSource, context: &MutationContext, ) -> AdminStoreResult; - /// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。 + /// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化。 async fn accept_artifact( &self, digest: &str, diff --git a/backend/crates/gateway-admin/src/service/mod.rs b/backend/crates/gateway-admin/src/service/mod.rs new file mode 100644 index 000000000..530b16aa6 --- /dev/null +++ b/backend/crates/gateway-admin/src/service/mod.rs @@ -0,0 +1,129 @@ +//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹。 + +mod registry; +mod settings; +pub use registry::Registry; + +use std::{future::Future, sync::Arc}; + +use futures::future::BoxFuture; +use gateway_core::{ + engine::middleware::FrozenMiddlewarePlan, + middleware::{ + compose, + service::{Context, Error, Value}, + }, +}; + +use crate::model::{AdminError, AdminErrorKind}; + +pub type PlanSource = Arc Option + Send + Sync>; + +/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合。 +#[derive(Clone, Default)] +pub enum Plan { + #[default] + Current, + Frozen(Option), +} + +/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析。 +#[derive(Clone)] +pub struct Origin { + pub request_id: String, + pub call_id: String, + pub cancellation: gateway_core::lifecycle::CancellationToken, + pub extensions: gateway_core::engine::extensions::ExtensionCallScope, + pub plan: Plan, +} + +impl From for Origin { + fn from(context: Context) -> Self { + Self { + request_id: context.request_id, + call_id: context.call_id, + cancellation: context.cancellation, + extensions: context.extensions, + plan: Plan::Frozen(Some(context.plan)), + } + } +} + +tokio::task_local! { static CURRENT: Origin; } + +/// task local 随 future poll 进入/退出,不泄漏给并行任务。 +pub async fn scope(context: impl Into, future: impl Future) -> T { + CURRENT.scope(context.into(), future).await +} + +pub(crate) async fn invoke( + source: &PlanSource, + operation: &'static str, + input: Value, + terminal: impl FnOnce(Value) -> BoxFuture<'static, Result> + Send + 'static, +) -> Result { + let parent = CURRENT.try_with(Clone::clone).ok(); + let plan = match parent.as_ref().map(|parent| &parent.plan) { + Some(Plan::Frozen(plan)) => plan.clone(), + _ => source(), + }; + let call_id = uuid::Uuid::now_v7().to_string(); + let parent_call_id = parent.as_ref().map(|parent| parent.call_id.clone()); + let origin = Origin { + request_id: parent + .as_ref() + .map_or_else(|| call_id.clone(), |parent| parent.request_id.clone()), + cancellation: parent + .as_ref() + .map(|parent| parent.cancellation.clone()) + .unwrap_or_default(), + extensions: parent.map(|parent| parent.extensions).unwrap_or_default(), + call_id, + plan: Plan::Frozen(plan.clone()), + }; + let Some(plan) = plan.filter(FrozenMiddlewarePlan::has_service) else { + return tokio::select! { + biased; + () = origin.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")), + result = scope(origin.clone(), terminal(input)) => result, + }; + }; + let context = Context { + operation, + request_id: origin.request_id, + call_id: origin.call_id, + parent_call_id, + cancellation: origin.cancellation, + extensions: origin.extensions, + plan: plan.clone(), + }; + let inner = context.clone(); + let next = compose(Vec::new(), move |input| { + Box::pin(scope(inner, terminal(input))) + }); + tokio::select! { + biased; + () = context.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")), + result = plan.handle_service(context.clone(), input, next) => result, + } +} + +pub(crate) fn encode_error(error: AdminError) -> Error { + Error { + kind: match error.kind() { + AdminErrorKind::Invalid => "invalid", + AdminErrorKind::Unauthorized => "unauthorized", + AdminErrorKind::Forbidden => "forbidden", + AdminErrorKind::NotFound => "not_found", + AdminErrorKind::Conflict => "conflict", + AdminErrorKind::RateLimited => "rate_limited", + AdminErrorKind::BadGateway => "bad_gateway", + AdminErrorKind::UpstreamResultUnknown => "upstream_result_unknown", + AdminErrorKind::Unavailable => "unavailable", + AdminErrorKind::Internal => "internal", + } + .into(), + message: error.message().to_owned(), + details: None, + } +} diff --git a/backend/crates/gateway-admin/src/service/registry.rs b/backend/crates/gateway-admin/src/service/registry.rs new file mode 100644 index 000000000..e3ea8c1d5 --- /dev/null +++ b/backend/crates/gateway-admin/src/service/registry.rs @@ -0,0 +1,76 @@ +//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务。 + +use crate::model::AdminError; +use futures::future::BoxFuture; +use gateway_core::middleware::service::{Error, Value}; +use serde::{Serialize, de::DeserializeOwned}; +use std::{collections::BTreeMap, sync::Arc}; + +type Handler = Arc BoxFuture<'static, Result> + Send + Sync>; + +pub struct Registry { + middleware: super::PlanSource, + operations: BTreeMap<&'static str, Handler>, +} + +impl Registry { + pub fn new(middleware: super::PlanSource) -> Self { + Self { + middleware, + operations: BTreeMap::new(), + } + } + + /// 网关与命令行复用同一组设置服务;注册不会执行读取或写入。 + pub fn register_settings( + &mut self, + service: &Arc, + ) -> Result<(), AdminError> { + super::settings::register(self, service) + } + + pub(crate) fn register( + &mut self, + name: &'static str, + handler: impl Fn(I) -> BoxFuture<'static, Result> + Send + Sync + 'static, + ) -> Result<(), AdminError> + where + I: DeserializeOwned + Send + 'static, + O: Serialize + Send + 'static, + { + let std::collections::btree_map::Entry::Vacant(entry) = self.operations.entry(name) else { + return Err(AdminError::internal(format!("公开服务重复登记:{name}"))); + }; + let handler = Arc::new(handler); + entry.insert(Arc::new(move |input| { + let handler = handler.clone(); + Box::pin(async move { + let input = serde_json::from_value(input) + .map_err(|_| Error::invalid("服务输入类型无效"))?; + let output = handler(input).await.map_err(super::encode_error)?; + serde_json::to_value(output).map_err(|_| Error::unavailable("服务结果编码失败")) + }) + })); + Ok(()) + } + + pub async fn call(&self, operation: &str, input: Value) -> Result { + let (operation, handler) = + self.operations + .get_key_value(operation) + .ok_or_else(|| Error { + details: None, + kind: "not_found".into(), + message: format!("公开服务不存在:{operation}"), + })?; + let handler = handler.clone(); + super::invoke(&self.middleware, operation, input, move |input| { + handler(input) + }) + .await + } + + pub fn operations(&self) -> impl Iterator + '_ { + self.operations.keys().copied() + } +} diff --git a/backend/crates/gateway-admin/src/service/settings.rs b/backend/crates/gateway-admin/src/service/settings.rs new file mode 100644 index 000000000..99e448f8a --- /dev/null +++ b/backend/crates/gateway-admin/src/service/settings.rs @@ -0,0 +1,85 @@ +//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成。 + +use crate::{ + SettingsService, + model::{ + AdminError, MutationContext, + pricing::{SyncPricing, UpdatePricing}, + settings::ReplaceRuntimeSettings, + }, +}; +use std::sync::Arc; + +pub(super) fn register( + registry: &mut super::Registry, + service: &Arc, +) -> Result<(), AdminError> { + // 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生。 + macro_rules! register { + ($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{ + let service = service.clone(); + registry.register(concat!("settings.", stringify!($method)), move |$pattern: $input| { + let service = service.clone(); + Box::pin(async move { service.$method($($argument),*).await }) + })?; + }}; + } + register!(Load, load, (), ()); + register!( + Replace, + replace, + (MutationContext, ReplaceRuntimeSettings), + (context, command), + &context, + command + ); + register!(ApiKeyExists, admin_api_key_exists, (), ()); + register!( + RegenerateApiKey, + regenerate_admin_api_key, + MutationContext, + context, + &context + ); + register!( + DeleteApiKey, + delete_admin_api_key, + MutationContext, + context, + &context + ); + register!(Pricing, pricing, (), ()); + register!(PreviewPricingSync, preview_pricing_sync, (), ()); + register!( + Sync, + sync_pricing, + (MutationContext, SyncPricing), + (context, command), + &context, + command + ); + register!( + Update, + update_pricing, + (MutationContext, UpdatePricing), + (context, command), + &context, + command + ); + register!( + ClientProfileOptions, + client_profile_options, + String, + provider, + &provider + ); + register!( + PreviewClientProfile, + preview_client_profile, + (String, Option), + (provider, configuration), + &provider, + configuration.as_ref() + ); + Ok(()) +} diff --git a/backend/crates/gateway-admin/src/use_case/plugin_update.rs b/backend/crates/gateway-admin/src/use_case/plugin_update.rs index 648a7305a..0ee92b7e1 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_update.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_update.rs @@ -85,10 +85,6 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight { .any(|(capability, version)| { !compatibility.supports_capability(capability, *version) }) - || requirements - .permissions - .iter() - .any(|permission| !compatibility.supports_permission(permission)) { return Err(incompatible(&instance.id)); } diff --git a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs index c85cdf0dd..b579e6dda 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs @@ -14,7 +14,7 @@ use crate::{ distribution::VerifiedPluginArtifact, instances::{ ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy, - PluginInstance, PluginPermissionGrant, + PluginInstance, }, }, }, @@ -52,13 +52,6 @@ pub struct PluginsService { } impl PluginsService { - pub fn permission_descriptions( - &self, - permissions: &[String], - ) -> Vec { - self.inspector.permission_descriptions(permissions) - } - #[must_use] pub fn new( store: Arc, @@ -384,11 +377,14 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec] = if capability == "observer" { + &[Some("request_completed"), Some("websocket_response")] + } else { + &[None] + }; + events.iter().map(move |event| PluginCapabilityBinding { contribution: contribution.id.clone(), stage: stage.clone(), order: 0, @@ -403,8 +399,10 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec Result<(), AdminError> { || !instance.configuration.is_object() || serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024) || instance.secrets.len() > 64 - || instance.grants.len() > 32 || instance.bindings.len() > 64 { return Err(AdminError::invalid("插件实例配置不合法或超过大小限制")); diff --git a/backend/crates/gateway-admin/src/use_case/plugins/management.rs b/backend/crates/gateway-admin/src/use_case/plugins/management.rs index d49efeded..377e00095 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/management.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/management.rs @@ -16,7 +16,7 @@ use crate::{ ports::{plugin_management::PluginManagement, plugins::PluginStore}, }; -/// 菜单是发布视图的投影;每次业务动作仍复核持久实例,旧页面不能保留已撤销权限。 +/// 菜单来自发布视图;每次业务动作复核持久实例,旧页面不能继续使用已停用或换版的目标。 pub struct PluginManagementService { runtime: Arc, store: Arc, @@ -25,12 +25,9 @@ pub struct PluginManagementService { impl PluginManagementService { /// 页面发起模型请求前复核持久目标与当前发布代次;长响应可重复调用以收敛撤销。 - pub async fn authorize_models( - &self, - target: &PluginManagementTarget, - ) -> Result<(), AdminError> { + pub async fn validate_target(&self, target: &PluginManagementTarget) -> Result<(), AdminError> { let reference = self.authorize(target).await?; - self.runtime.authorize_models(&reference, target).await + self.runtime.validate_target(&reference, target).await } pub async fn start_callback( diff --git a/backend/crates/gateway-admin/src/use_case/plugins/versions.rs b/backend/crates/gateway-admin/src/use_case/plugins/versions.rs index 28be61efa..9f5596a63 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/versions.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/versions.rs @@ -252,10 +252,11 @@ fn version_bindings( let previous = capability.and_then(|capability| current.contributes.get(capability)); previous.is_none_or(|contribution| { !contribution.stages.contains(&binding.stage) - || instance - .bindings - .iter() - .any(|old| old.contribution == contribution.id && old.stage == binding.stage) + || instance.bindings.iter().any(|old| { + old.contribution == contribution.id + && old.stage == binding.stage + && old.event == binding.event + }) }) }); for previous in &instance.bindings { @@ -276,7 +277,9 @@ fn version_bindings( let mut binding = previous.clone(); binding.contribution.clone_from(&contribution.id); if let Some(default) = bindings.iter_mut().find(|default| { - default.contribution == binding.contribution && default.stage == binding.stage + default.contribution == binding.contribution + && default.stage == binding.stage + && default.event == binding.event }) { *default = binding; } else { diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index ccf4e9131..5a1a89470 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -102,6 +102,50 @@ impl DefaultSettingsService { } } +fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> { + let valid = command.request_location.validate().is_ok() + && command.responses_max_decompressed_body_bytes > 0 + && isize::try_from(command.responses_max_decompressed_body_bytes).is_ok() + && command.refresh_margin_seconds > 0 + && command.refresh_concurrency > 0 + && command.max_waiting_per_key <= 1_000 + && command.max_waiting_per_account <= 1_000 + && (1..=120).contains(&command.concurrency_wait_timeout_seconds) + && command.usage_retention_days >= 31 + && command.ops_event_retention_days > 0 + && command.audit_retention_days > 0 + && valid_client_version(command.min_codex_desktop_version.as_deref()) + && valid_client_version(command.min_codex_cli_version.as_deref()) + && valid_probe_model(command.account_auto_freeze_probe_model.as_deref()) + && gateway_core::provider_ports::valid_warmup_schedule_time( + &command.account_warmup_schedule_time, + ) + && valid_probe_model(command.account_warmup_model.as_deref()) + && (!command.account_warmup_enabled || command.account_warmup_model.is_some()) + && i64::try_from(command.request_interval_ms).is_ok() + && (2..=1_000).contains(&command.account_auto_freeze_threshold) + && (60..=3_600).contains(&command.account_auto_freeze_window_seconds) + && (300..=604_800).contains(&command.account_auto_freeze_duration_seconds); + if valid { + Ok(()) + } else { + Err(AdminError::invalid("运行时设置不满足约束")) + } +} + +fn valid_client_version(value: Option<&str>) -> bool { + value.is_none_or(|value| CodexClientVersion::parse(value).is_ok()) +} + +fn valid_probe_model(value: Option<&str>) -> bool { + value.is_none_or(|value| { + !value.is_empty() + && value.len() <= 128 + && value == value.trim() + && !value.bytes().any(|byte| byte.is_ascii_control()) + }) +} + #[async_trait] impl SettingsService for DefaultSettingsService { async fn preview_pricing_sync( @@ -365,7 +409,17 @@ impl SettingsService for DefaultSettingsService { .store .replace_runtime_settings(command, context) .await - .map_err(|error| map_store_error(error, "runtime settings"))?; + .map_err(|error| { + if matches!( + error.kind(), + crate::ports::store::AdminStoreErrorKind::Conflict + | crate::ports::store::AdminStoreErrorKind::StaleRevision + ) { + AdminError::conflict("运行设置已被其他调用修改,请重新读取并确认后提交") + } else { + map_store_error(error, "runtime settings") + } + })?; publish_committed(self.snapshot.as_ref(), settings.config_revision).await?; Ok(settings) } @@ -406,47 +460,3 @@ impl SettingsService for DefaultSettingsService { Ok(mutation) } } - -fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> { - let valid = command.request_location.validate().is_ok() - && command.responses_max_decompressed_body_bytes > 0 - && isize::try_from(command.responses_max_decompressed_body_bytes).is_ok() - && command.refresh_margin_seconds > 0 - && command.refresh_concurrency > 0 - && command.max_waiting_per_key <= 1_000 - && command.max_waiting_per_account <= 1_000 - && (1..=120).contains(&command.concurrency_wait_timeout_seconds) - && command.usage_retention_days >= 31 - && command.ops_event_retention_days > 0 - && command.audit_retention_days > 0 - && valid_client_version(command.min_codex_desktop_version.as_deref()) - && valid_client_version(command.min_codex_cli_version.as_deref()) - && valid_probe_model(command.account_auto_freeze_probe_model.as_deref()) - && gateway_core::provider_ports::valid_warmup_schedule_time( - &command.account_warmup_schedule_time, - ) - && valid_probe_model(command.account_warmup_model.as_deref()) - && (!command.account_warmup_enabled || command.account_warmup_model.is_some()) - && i64::try_from(command.request_interval_ms).is_ok() - && (2..=1_000).contains(&command.account_auto_freeze_threshold) - && (60..=3_600).contains(&command.account_auto_freeze_window_seconds) - && (300..=604_800).contains(&command.account_auto_freeze_duration_seconds); - if valid { - Ok(()) - } else { - Err(AdminError::invalid("运行时设置不满足约束")) - } -} - -fn valid_client_version(value: Option<&str>) -> bool { - value.is_none_or(|value| CodexClientVersion::parse(value).is_ok()) -} - -fn valid_probe_model(value: Option<&str>) -> bool { - value.is_none_or(|value| { - !value.is_empty() - && value.len() <= 128 - && value == value.trim() - && !value.bytes().any(|byte| byte.is_ascii_control()) - }) -} diff --git a/backend/crates/gateway-admin/tests/model/mod.rs b/backend/crates/gateway-admin/tests/model/mod.rs index 9fd0a88f2..5f7898549 100644 --- a/backend/crates/gateway-admin/tests/model/mod.rs +++ b/backend/crates/gateway-admin/tests/model/mod.rs @@ -1,6 +1,7 @@ //! 模型模块测试:镜像 src/model/ 中带逻辑的模块。 mod backup; +mod plugins; mod provider_credentials; mod quota_forecast; mod quota_forecast_sampling; diff --git a/backend/crates/gateway-admin/tests/model/plugins/mod.rs b/backend/crates/gateway-admin/tests/model/plugins/mod.rs new file mode 100644 index 000000000..dc4c2003a --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/plugins/mod.rs @@ -0,0 +1,105 @@ +use gateway_admin::model::plugins::PluginArtifactMetadata; +use serde_json::{Value, json}; + +fn metadata() -> Value { + json!({ + "pluginId": "test.example", "version": "1.0.0", "name": "example", + "displayName": "Example", "publisher": "test", "description": "Example plugin", + "license": "MIT", "sha256": "a".repeat(64), "platforms": ["linux-x86_64"], + "contributes": {"middleware": { + "id": "test.example.middleware", "version": 3, + "stages": [], "inputFormats": [], "outputFormats": [] + }}, + "configurationSchema": {}, "secretFields": [], "stateNamespaces": [] + }) +} + +#[test] +fn metadata_ignores_unknown_fields_at_each_object_boundary() { + let mut original = metadata(); + original["icon"] = json!({"light": "light.svg", "dark": "dark.svg"}); + original["stateNamespaces"] = json!([{ + "namespace": "cache", "schemaVersion": 1, "schemaSha256": "b".repeat(64), + "schema": {}, "maximumRecords": 10, "maximumBytes": 1024, + "maximumValueBytes": 128, "migratesFrom": [] + }]); + let expected: PluginArtifactMetadata = serde_json::from_value(original.clone()).unwrap(); + for path in ["", "/contributes/middleware", "/icon", "/stateNamespaces/0"] { + let mut extended = original.clone(); + extended.pointer_mut(path).unwrap()["futureField"] = json!({"enabled": true}); + assert_eq!( + serde_json::from_value::(extended).unwrap(), + expected, + "unknown field at {path}" + ); + } +} + +#[test] +fn metadata_defaults_only_optional_fields() { + let original = metadata(); + let expected: PluginArtifactMetadata = serde_json::from_value(original.clone()).unwrap(); + let mut older = original; + for field in ["configurationSchema", "secretFields", "stateNamespaces"] { + older.as_object_mut().unwrap().remove(field); + } + for field in ["stages", "inputFormats", "outputFormats"] { + older["contributes"]["middleware"] + .as_object_mut() + .unwrap() + .remove(field); + } + assert_eq!( + serde_json::from_value::(older).unwrap(), + expected + ); +} + +#[test] +fn metadata_still_requires_identity_and_execution_facts() { + for field in [ + "pluginId", + "version", + "name", + "publisher", + "sha256", + "platforms", + "contributes", + ] { + let mut incomplete = metadata(); + incomplete.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(incomplete).is_err(), + "missing {field}" + ); + } + for field in ["id", "version"] { + let mut incomplete = metadata(); + incomplete["contributes"]["middleware"] + .as_object_mut() + .unwrap() + .remove(field); + assert!( + serde_json::from_value::(incomplete).is_err(), + "missing contribution {field}" + ); + } +} + +#[test] +fn metadata_still_rejects_wrong_types_in_known_fields() { + for (path, value) in [ + ("/version", json!(1)), + ("/secretFields", json!(false)), + ("/stateNamespaces", json!({})), + ("/contributes/middleware/version", json!("3")), + ("/contributes/middleware/stages", json!("http")), + ] { + let mut invalid = metadata(); + *invalid.pointer_mut(path).unwrap() = value; + assert!( + serde_json::from_value::(invalid).is_err(), + "wrong type at {path}" + ); + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 670b069af..84c051efe 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -105,6 +105,7 @@ pub(super) struct AdminHarness { plugin_store: Arc, plugin_inspector: Arc, client_key_verifier: Arc, + service_middleware: gateway_admin::service::PlanSource, } impl AdminHarness { @@ -132,6 +133,7 @@ impl AdminHarness { plugin_store: Arc::new(plugins::TestPluginPorts), plugin_inspector: Arc::new(plugins::TestPluginPorts), client_key_verifier: Arc::new(UnavailableClientKeyVerifier), + service_middleware: Arc::new(|| None), } } @@ -190,6 +192,11 @@ impl AdminHarness { self } + pub(super) fn service_middleware(mut self, source: gateway_admin::service::PlanSource) -> Self { + self.service_middleware = source; + self + } + pub(super) fn backup(mut self, backup: BackupStorePorts) -> Self { self.backup = backup; self @@ -261,6 +268,7 @@ impl AdminHarness { Arc::new(plugins::TestPluginPorts), ), gateway_admin::AdminRuntimePorts { + service_middleware: self.service_middleware, plugin_preparation: Arc::new(plugins::TestPluginPorts), plugin_management: Arc::new(plugins::TestPluginPorts), published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle::default(), @@ -1101,3 +1109,4 @@ fn unavailable_system() -> SystemOperationError { "unavailable in this test", ) } +mod service_contract; diff --git a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs index 615520143..8529c3cf4 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs @@ -103,7 +103,7 @@ impl PluginStore for Fixture { trusted_process: true, configuration: serde_json::json!({}), secrets: BTreeMap::new(), - grants: Vec::new(), + bindings: Vec::new(), revision: Revision::new(1).expect("revision"), }], @@ -126,7 +126,7 @@ impl PluginStore for Fixture { platforms: Vec::new(), icon: None, contributes: BTreeMap::new(), - requested_permissions: Vec::new(), + configuration_schema: serde_json::json!({}), secret_fields: Vec::new(), state_namespaces: Vec::new(), @@ -285,11 +285,11 @@ impl SystemOperations for PreflightingSystem { #[tokio::test] async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() { - let fixture = Fixture::new(requirements("^1.0", "executor", "log"), &[7]); + let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]); let services = super::AdminHarness::new() .plugins(fixture.clone(), fixture) .system(Arc::new(PreflightingSystem { - candidate: candidate("executor", "log"), + candidate: candidate("executor"), })) .build() .await; @@ -303,11 +303,11 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() #[tokio::test] async fn system_update_rejects_missing_target_capability_and_revision_changes() { - let incompatible = Fixture::new(requirements("^1.0", "executor", "log"), &[7]); + let incompatible = Fixture::new(requirements("^1.0", "executor"), &[7]); let services = super::AdminHarness::new() .plugins(incompatible.clone(), incompatible) .system(Arc::new(PreflightingSystem { - candidate: candidate("models", "log"), + candidate: candidate("models"), })) .build() .await; @@ -319,11 +319,11 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes() .is_err() ); - let stale = Fixture::new(requirements("^1.0", "executor", "log"), &[7, 8]); + let stale = Fixture::new(requirements("^1.0", "executor"), &[7, 8]); let services = super::AdminHarness::new() .plugins(stale.clone(), stale) .system(Arc::new(PreflightingSystem { - candidate: candidate("executor", "log"), + candidate: candidate("executor"), })) .build() .await; @@ -336,32 +336,26 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes() ); } -fn requirements( - host_version: &str, - capability: &str, - permission: &str, -) -> PluginCompatibilityRequirements { +fn requirements(host_version: &str, capability: &str) -> PluginCompatibilityRequirements { PluginCompatibilityRequirements { host_version: host_version.into(), manifest_schema_version: 2, protocol_version: 3, capabilities: vec![(capability.into(), 1)], - permissions: vec![permission.into()], } } -fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate { +fn candidate(capability: &str) -> SystemUpdateCandidate { let manifest = serde_json::to_vec(&serde_json::json!({ "schema_version": 1, "sealed": true, "gateway_version": "1.2.0", "gateway_git_sha": "a".repeat(40), "plugin_host": { - "schema_version": 1, + "schema_version": 2, "manifest_schema_versions": [2], "protocol_versions": [3], "capabilities": [{ "capability": capability, "versions": [1] }], - "permissions": [permission], }, "plugins": [], })) diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs index 69a8e1a76..21913a943 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs @@ -337,7 +337,7 @@ async fn existing_plugin_instance_prevents_an_extra_default_instance() { trusted_process: false, configuration: json!({}), secrets: BTreeMap::new(), - grants: Vec::new(), + bindings: Vec::new(), revision: revision(1), }; @@ -389,7 +389,7 @@ fn metadata(with_required_default: bool) -> PluginArtifactMetadata { contribution("test.example.middleware", &["observation", "request"]), ), ]), - requested_permissions: vec!["network".into()], + configuration_schema: json!({ "type":"object", "properties": { diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs b/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs index 810514ce7..9cb6ab567 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs @@ -519,7 +519,7 @@ fn preview_fixture() -> Fixture { platforms: vec!["linux-x86_64".into()], icon: None, contributes: Default::default(), - requested_permissions: vec!["log".into()], + configuration_schema: serde_json::json!({"type":"object"}), secret_fields: vec![], state_namespaces: vec![], @@ -635,7 +635,6 @@ async fn verifying_remote_package_uses_inspector_and_returns_fixed_facts_without .await .unwrap(); assert_eq!(verified.metadata.sha256, "a".repeat(64)); - assert_eq!(verified.metadata.requested_permissions, vec!["log"]); assert!( matches!(verified.source, PluginSource::Github { ref repository, ref tag, ref asset, .. } if repository == "owner/plugin" && tag == "v2.0.0" && asset == "plugin.tar.gz") 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 5957194c7..f52b5a773 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs @@ -20,8 +20,7 @@ use gateway_admin::{ ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, - PluginInstanceSnapshot, PluginPermissionGrant, PluginVersionConfiguration, - RollbackPluginInstance, + PluginInstanceSnapshot, PluginVersionConfiguration, RollbackPluginInstance, }, state::{ ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, @@ -103,7 +102,7 @@ impl LifecycleFixture { trusted_process: true, configuration: json!({}), secrets: Default::default(), - grants: vec![], + bindings: vec![], revision: revision(10), }], @@ -599,7 +598,7 @@ fn artifact(digest: &str, plugin_id: &str, version: &str) -> InstalledPluginArti output_formats: Vec::new(), }, )]), - requested_permissions: vec![], + configuration_schema: json!({"type":"object"}), secret_fields: vec![], state_namespaces: vec![], @@ -998,13 +997,12 @@ async fn failed_state_migration_never_overwrites_a_concurrent_configuration_chan } #[tokio::test] -async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives_permissions() { +async fn rollback_restores_target_configuration_secrets_and_bindings() { use secrecy::ExposeSecret as _; let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); { let mut data = fixture.data.lock().unwrap(); - data.artifacts[1].metadata.requested_permissions = vec!["network".into()]; let instance = &mut data.snapshot.instances[0]; instance.configuration = json!({"label":"retained"}); instance @@ -1019,6 +1017,7 @@ async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives account_group_ids: Vec::new(), provider_ids: Vec::new(), models: Vec::new(), + event: None, identity_bindings: Vec::new(), }); } @@ -1031,12 +1030,6 @@ async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives assert_eq!(result.instance.name, before.name); assert_eq!(result.instance.enabled, before.enabled); assert_eq!(result.instance.configuration, json!({"oldVersion": true})); - assert_eq!( - result.instance.grants, - [PluginPermissionGrant { - permission: "network".into() - }] - ); assert!(result.instance.bindings.is_empty()); assert_eq!( result.instance.secrets["token"].expose_secret(), @@ -1275,6 +1268,7 @@ async fn version_plan_keeps_explicit_values_adds_defaults_and_remaps_bindings_wi account_group_ids: vec![], provider_ids: vec![], models: vec!["test-model".into()], + event: None, identity_bindings: vec![], }]; } @@ -1399,3 +1393,56 @@ async fn switching_versions_uses_saved_settings_and_rejects_stale_retry() { assert_eq!(error.kind(), AdminErrorKind::Conflict); assert_eq!(fixture.snapshot().config_revision, revision); } + +#[tokio::test] +async fn version_plan_preserves_observer_event_scopes_and_disabled_subscriptions() { + for include_websocket in [false, true] { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.history.clear(); + for (index, artifact) in data.artifacts.iter_mut().enumerate() { + artifact.metadata.contributes.clear(); + artifact.metadata.contributes.insert( + "observer".into(), + PluginContribution { + id: format!("observer-{index}"), + version: 1, + stages: vec!["observation".into()], + input_formats: vec![], + output_formats: vec![], + }, + ); + } + data.snapshot.instances[0].bindings = ["request_completed", "websocket_response"] + .into_iter() + .take(if include_websocket { 2 } else { 1 }) + .map(|event| PluginCapabilityBinding { + contribution: "observer-0".into(), + stage: "observation".into(), + event: Some(event.into()), + order: 7, + failure_policy: PluginFailurePolicy::Observe, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![format!("model-{event}")], + identity_bindings: vec![], + }) + .collect(); + } + let before = fixture.snapshot(); + let plan = service(fixture.clone(), Arc::new(Published::default())) + .version_plan(&before.instances[0].id, NEW_ARTIFACT) + .await + .unwrap(); + assert_eq!(plan.bindings.len(), before.instances[0].bindings.len()); + for (binding, previous) in plan.bindings.iter().zip(&before.instances[0].bindings) { + assert_eq!(binding.contribution, "observer-1"); + assert_eq!(binding.event, previous.event); + assert_eq!(binding.models, previous.models); + assert_eq!(binding.order, previous.order); + } + assert!(fixture.data.lock().unwrap().saves.is_empty()); + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs b/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs index c813dbc45..b5b4210ec 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs @@ -107,7 +107,7 @@ impl gateway_admin::ports::proxy::ProxyStore for TestPluginPorts { #[async_trait] impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts { - async fn authorize_models( + async fn validate_target( &self, _: &gateway_core::runtime::extensions::ExtensionSetReference, _: &gateway_admin::model::plugins::management::PluginManagementTarget, diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/official.rs b/backend/crates/gateway-admin/tests/use_case/plugins/official.rs index bb37f0d6d..ec0fead27 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/official.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/official.rs @@ -142,7 +142,7 @@ impl PluginPackageInspector for Fixture { )], icon: None, contributes: Default::default(), - requested_permissions: Vec::new(), + configuration_schema: serde_json::json!({}), secret_fields: Vec::new(), state_namespaces: Vec::new(), @@ -372,11 +372,10 @@ fn identity() -> OfficialPluginReleaseIdentity { fn plugin_host() -> serde_json::Value { serde_json::json!({ - "schema_version": 1, + "schema_version": 2, "manifest_schema_versions": [2], "protocol_versions": [3], "capabilities": [{ "capability": "executor", "versions": [1] }], - "permissions": ["log"], }) } diff --git a/backend/crates/gateway-admin/tests/use_case/service_contract.rs b/backend/crates/gateway-admin/tests/use_case/service_contract.rs new file mode 100644 index 000000000..89c94119e --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/service_contract.rs @@ -0,0 +1,290 @@ +//! 从宿主类型和操作声明生成独立 SDK 合同;普通测试阻止两侧声明漂移。 + +use std::{collections::BTreeMap, path::Path}; + +use quote::quote; +use syn::{Item, Type, parse::Parse, parse_quote, visit_mut::VisitMut}; + +struct Operation { + name: syn::Ident, + method: syn::Ident, + input: Type, +} + +impl Parse for Operation { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let name = input.parse()?; + input.parse::()?; + let method = input.parse()?; + input.parse::()?; + let arguments = input.parse()?; + // 后续模式和参数表达式只属于宿主调用,不进入线协议。 + input.parse::()?; + let _ = syn::Pat::parse_single(input)?; + while !input.is_empty() { + input.parse::()?; + if !input.is_empty() { + let _ = input.parse::()?; + } + } + Ok(Self { + name, + method, + input: arguments, + }) + } +} + +fn source(root: &Path, path: &str) -> syn::File { + syn::parse_file(&std::fs::read_to_string(root.join(path)).unwrap()).unwrap() +} + +// SDK 保留 wire 值,不依赖宿主的领域构造器、时区库或价格校验实现。 +struct WireTypes; +impl VisitMut for WireTypes { + fn visit_type_mut(&mut self, ty: &mut Type) { + if let Type::Path(path) = ty { + assert!(path.qself.is_none(), "服务合同不支持关联类型"); + if path + .path + .segments + .first() + .is_some_and(|segment| segment.ident == "serde_json") + { + syn::visit_mut::visit_type_mut(self, ty); + return; + } + let last = path.path.segments.last().unwrap().clone(); + *ty = match last.ident.to_string().as_str() { + "DateTime" | "Tz" | "TokenPrice" | "AdminApiKey" => parse_quote!(String), + "OpaqueProviderData" => parse_quote!(serde_json::Map), + _ => Type::Path(syn::TypePath { + qself: None, + path: last.into(), + }), + }; + } + syn::visit_mut::visit_type_mut(self, ty); + } +} + +fn contract_type(mut item: Item, name: &str) -> Item { + let (attrs, ident, fields, comparable) = match &mut item { + Item::Struct(item) => { + item.vis = parse_quote!(pub); + let comparable = !matches!( + name, + "RuntimeSettings" + | "ReplaceRuntimeSettings" + | "AdminApiKeyMutation" + | "RegeneratedAdminApiKey" + ); + ( + &mut item.attrs, + &mut item.ident, + Some(&mut item.fields), + comparable, + ) + } + Item::Enum(item) => (&mut item.attrs, &mut item.ident, None, true), + _ => panic!("公开合同必须是 struct 或 enum"), + }; + *ident = syn::parse_str(name).unwrap(); + attrs.retain(|attr| attr.path().is_ident("serde")); + attrs.insert( + 0, + if name == "SmartSchedulingConfig" { + parse_quote!(#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]) + } else if comparable { + parse_quote!(#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]) + } else { + parse_quote!(#[derive(Clone, Serialize, Deserialize)]) + }, + ); + if let Some(fields) = fields { + for field in fields { + field.vis = parse_quote!(pub); + } + } + WireTypes.visit_item_mut(&mut item); + item +} + +#[test] +fn sdk_settings_contract_matches_host_declarations() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut types = Vec::new(); + let mut replacement_fields = Vec::new(); + for (path, names) in [ + ( + "src/model/settings.rs", + &[ + "RuntimeSettings", + "ReplaceRuntimeSettings", + "AdminApiKeyMutation", + "RegeneratedAdminApiKey", + ][..], + ), + ( + "src/model/mod.rs", + &["MutationActor", "MutationContext"][..], + ), + ( + "../gateway-core/src/account/location.rs", + &["RequestLocation"][..], + ), + ( + "../gateway-core/src/account/smart_scheduling.rs", + &["SmartSchedulingValues"][..], + ), + ( + "../gateway-core/src/metering/pricing.rs", + &["TokenPriceOverride", "ModelPriceOverride"][..], + ), + ( + "src/model/pricing.rs", + &[ + "PricingCatalog", + "PricingSyncPreview", + "SyncPricing", + "PricingChange", + "UpdatePricing", + ][..], + ), + ] { + let items = source(root, path).items; + for name in names { + let item = items + .iter() + .find(|item| match item { + Item::Struct(item) => item.ident == name, + Item::Enum(item) => item.ident == name, + _ => false, + }) + .unwrap_or_else(|| panic!("缺少合同类型 {name}")); + if let Item::Struct(item) = item + && item.ident == "ReplaceRuntimeSettings" + { + replacement_fields = item + .fields + .iter() + .map(|field| field.ident.clone().unwrap()) + .collect(); + } + types.push(contract_type( + item.clone(), + if *name == "SmartSchedulingValues" { + "SmartSchedulingConfig" + } else { + name + }, + )); + } + } + + let service = source(root, "src/use_case/settings.rs") + .items + .into_iter() + .find_map(|item| match item { + Item::Trait(item) if item.ident == "SettingsService" => Some(item), + _ => None, + }) + .unwrap(); + let outputs: BTreeMap<_, _> = service + .items + .into_iter() + .filter_map(|item| match item { + syn::TraitItem::Fn(method) => { + let syn::ReturnType::Type(_, output) = method.sig.output else { + panic!("服务必须返回 Result") + }; + let Type::Path(result) = *output else { + panic!("服务必须返回 Result") + }; + let segment = result.path.segments.last().unwrap(); + assert_eq!(segment.ident, "Result"); + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + panic!("Result 缺少类型参数") + }; + let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone() + else { + panic!("Result 缺少输出类型") + }; + WireTypes.visit_type_mut(&mut output); + Some((method.sig.ident.to_string(), output)) + } + _ => None, + }) + .collect(); + let register = source(root, "src/service/settings.rs") + .items + .into_iter() + .find_map(|item| match item { + Item::Fn(item) if item.sig.ident == "register" => Some(item), + _ => None, + }) + .unwrap(); + let operations: Vec<_> = register + .block + .stmts + .into_iter() + .filter_map(|stmt| { + let syn::Stmt::Macro(stmt) = stmt else { + return None; + }; + if !stmt.mac.path.is_ident("register") { + return None; + } + let Operation { + name, + method, + mut input, + } = syn::parse2(stmt.mac.tokens).unwrap(); + WireTypes.visit_type_mut(&mut input); + let output = outputs.get(&method.to_string()).unwrap(); + let operation = format!("settings.{method}"); + Some(quote! { + pub struct #name; + impl Operation for #name { + const NAME: &'static str = #operation; + type Input = #input; + type Output = #output; + } + }) + }) + .collect(); + assert_eq!(operations.len(), outputs.len(), "公开服务方法必须完整声明"); + let assignments = replacement_fields.iter().map(|field| match field.to_string().as_str() { + "expected_revision" => quote!(expected_revision: settings.config_revision), + "request_profile_updates" => quote!(request_profile_updates: settings.request_profiles.into_iter().map(|(provider, value)| (provider, Some(value))).collect()), + _ => quote!(#field: settings.#field), + }); + let generated = quote! { + use super::Operation; + use serde::{Deserialize, Serialize}; + use std::{collections::{BTreeMap, BTreeSet}, num::NonZeroU64}; + pub type ProviderRequestProfiles = BTreeMap>; + pub type ProviderRequestProfileUpdates = BTreeMap>>; + pub type ModelMappings = BTreeMap; + pub type Revision = NonZeroU64; + pub type RotationStrategy = String; + pub type PricingOverrides = BTreeMap>; + #(#types)* + #(#operations)* + impl From for ReplaceRuntimeSettings { + fn from(settings: RuntimeSettings) -> Self { + Self { #(#assignments),* } + } + } + }; + let output = root.join("../gateway-plugin/sdk/src/call/services/settings.rs"); + let expected = prettyplease::unparse(&syn::parse2::(generated).unwrap()); + if std::env::var_os("CPR_UPDATE_SERVICE_CONTRACT").is_some() { + std::fs::write(&output, format!("// 从宿主设置类型与 service/settings.rs 生成;更新命令见 SDK 维护说明。\n{expected}")).unwrap(); + } + let actual = syn::parse_file(&std::fs::read_to_string(output).unwrap()).unwrap(); + assert!( + prettyplease::unparse(&actual) == expected, + "SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_settings_contract_matches_host_declarations" + ); +} diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 5ab386caf..ddd8ba2db 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -24,6 +24,66 @@ struct PricingSettingsStore { updated: Mutex>, } +#[tokio::test] +async fn explicit_service_calls_freeze_empty_plan_and_cancel_before_entering_terminal() { + use gateway_admin::service::{Origin, Plan, scope}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let resolutions = Arc::new(AtomicUsize::new(0)); + let source_calls = resolutions.clone(); + let store = Arc::new(PricingSettingsStore::default()); + let services = super::AdminHarness::new() + .settings(store.clone()) + .service_middleware(Arc::new(move || { + source_calls.fetch_add(1, Ordering::SeqCst); + None + })) + .build() + .await; + let context = MutationContext { + actor: gateway_admin::model::MutationActor::System, + request_id: "empty-plan".into(), + }; + let command = UpdatePricing { + provider: "xai".into(), + models: vec!["example-model".into()], + change: PricingChange::Reset, + }; + for expected in 1..=2 { + services + .public_services() + .call( + "settings.update_pricing", + serde_json::to_value((&context, command.clone())).unwrap(), + ) + .await + .unwrap(); + // 内部 pricing 不再单独进入插件链;显式调用只解析一次发布集合。 + assert_eq!(resolutions.load(Ordering::SeqCst), expected); + } + store.updated.lock().unwrap().take(); + let cancellation = gateway_core::lifecycle::CancellationToken::new(); + cancellation.cancel(); + let error = scope( + Origin { + request_id: "cancelled-native".into(), + call_id: "entry".into(), + cancellation, + extensions: Default::default(), + plan: Plan::Frozen(None), + }, + services.public_services().call( + "settings.update_pricing", + serde_json::to_value((context, command)).unwrap(), + ), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "unavailable"); + assert!(store.updated.lock().unwrap().is_none()); + assert_eq!(resolutions.load(Ordering::SeqCst), 2); +} + #[tokio::test] async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() { let services = super::AdminHarness::new() @@ -213,6 +273,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { request_id: "request-settings".to_owned(), }, ReplaceRuntimeSettings { + expected_revision: gateway_admin::model::Revision::new(1).unwrap(), request_profile_updates: Default::default(), request_location_enabled: false, request_location: Default::default(), diff --git a/backend/crates/gateway-api/Cargo.toml b/backend/crates/gateway-api/Cargo.toml index e76e0c8fa..d3c5a4a00 100644 --- a/backend/crates/gateway-api/Cargo.toml +++ b/backend/crates/gateway-api/Cargo.toml @@ -18,6 +18,9 @@ bytes = "1.11.1" chrono = { version = "0.4.45", features = ["serde"] } flate2 = "1.1.9" futures = "0.3.32" +http = "1.5.0" +http-body = "1.0.1" +http-body-util = "0.1.5" gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } gateway-protocol = { path = "../gateway-protocol" } @@ -28,6 +31,7 @@ serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve sha2 = "0.11.0" thiserror = "2.0.18" tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] } +tower = { version = "0.5.3", features = ["util"] } tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] } tracing = "0.1.44" url = "2.5.8" @@ -39,7 +43,6 @@ async-trait = "0.1.89" axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] } tokio = { version = "1.52.3", default-features = false, features = ["macros", "net", "rt-multi-thread", "test-util"] } tokio-tungstenite = "0.29.0" -tower = { version = "0.5.3", features = ["util"] } [lints] workspace = true diff --git a/backend/crates/gateway-api/src/admin/auth.rs b/backend/crates/gateway-api/src/admin/auth.rs index 894d0b3e1..8439d2fde 100644 --- a/backend/crates/gateway-api/src/admin/auth.rs +++ b/backend/crates/gateway-api/src/admin/auth.rs @@ -32,6 +32,12 @@ where type Rejection = AdminError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // 仅内部 Dispatcher 写入此上下文;普通客户端 headers 无法声明插件身份。 + if let Some(context) = parts.extensions.get::() { + return Ok(Self { + context: context.clone(), + }); + } let principal = require_admin_auth(state, &parts.headers).await?; let request_id = admin_request_id(parts).ok_or_else(AdminError::internal)?; Ok(Self { diff --git a/backend/crates/gateway-api/src/admin/plugins/artifacts.rs b/backend/crates/gateway-api/src/admin/plugins/artifacts.rs index 41a3afbdb..95b8f9b72 100644 --- a/backend/crates/gateway-api/src/admin/plugins/artifacts.rs +++ b/backend/crates/gateway-api/src/admin/plugins/artifacts.rs @@ -8,7 +8,7 @@ use axum::{ }; use gateway_admin::model::plugins::{ InstalledPluginArtifact, PluginArtifactMetadata, PluginIconTheme, PluginInstallResult, - PluginPermissionDescription, PluginSource, distribution::VerifiedPluginArtifact, + PluginSource, distribution::VerifiedPluginArtifact, }; use serde::{Deserialize, Serialize}; @@ -23,16 +23,16 @@ use crate::{ #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct ArtifactView { - metadata: MetadataView, + metadata: PluginArtifactMetadata, source: PluginSource, installed_at: String, accepted_at: Option, } impl ArtifactView { - fn new(value: InstalledPluginArtifact, service: &gateway_admin::PluginsService) -> Self { + fn new(value: InstalledPluginArtifact) -> Self { Self { - metadata: MetadataView::new(value.metadata, service), + metadata: value.metadata, source: value.source, installed_at: value.installed_at.to_rfc3339(), accepted_at: value.accepted_at.map(|value| value.to_rfc3339()), @@ -40,37 +40,16 @@ impl ArtifactView { } } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct MetadataView { - #[serde(flatten)] - metadata: PluginArtifactMetadata, - permission_descriptions: Vec, -} - -impl MetadataView { - fn new(metadata: PluginArtifactMetadata, service: &gateway_admin::PluginsService) -> Self { - Self { - permission_descriptions: service - .permission_descriptions(&metadata.requested_permissions), - metadata, - } - } -} - #[derive(Serialize)] pub(super) struct VerifiedArtifactView { - metadata: MetadataView, + metadata: PluginArtifactMetadata, source: PluginSource, } impl VerifiedArtifactView { - pub(super) fn new( - value: VerifiedPluginArtifact, - service: &gateway_admin::PluginsService, - ) -> Self { + pub(super) fn new(value: VerifiedPluginArtifact) -> Self { Self { - metadata: MetadataView::new(value.metadata, service), + metadata: value.metadata, source: value.source, } } @@ -86,9 +65,9 @@ pub(super) struct InstallResultView { } impl InstallResultView { - pub(super) fn new(value: PluginInstallResult, service: &gateway_admin::PluginsService) -> Self { + pub(super) fn new(value: PluginInstallResult) -> Self { Self { - artifact: ArtifactView::new(value.mutation.artifact, service), + artifact: ArtifactView::new(value.mutation.artifact), config_revision: value.mutation.config_revision.get(), default_instance_id: value.default_instance_id, configuration_required: value.configuration_required, @@ -173,7 +152,7 @@ async fn list( .await .map_err(map_admin_service_error)? .into_iter() - .map(|artifact| ArtifactView::new(artifact, state.admin_services().plugins())) + .map(ArtifactView::new) .collect(); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(items))) } @@ -200,10 +179,7 @@ async fn verify_upload( .map_err(map_admin_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(VerifiedArtifactView::new( - result, - state.admin_services().plugins(), - )), + AdminEnvelope::ok(VerifiedArtifactView::new(result)), )) } @@ -228,10 +204,7 @@ async fn upload( .map_err(map_admin_service_error)?; Ok(AdminResponse::new( StatusCode::CREATED, - AdminEnvelope::ok(InstallResultView::new( - result, - state.admin_services().plugins(), - )), + AdminEnvelope::ok(InstallResultView::new(result)), )) } @@ -254,10 +227,7 @@ async fn accept( .map_err(map_admin_service_error)?; Ok(AdminResponse::new( StatusCode::CREATED, - AdminEnvelope::ok(InstallResultView::new( - result, - state.admin_services().plugins(), - )), + AdminEnvelope::ok(InstallResultView::new(result)), )) } diff --git a/backend/crates/gateway-api/src/admin/plugins/distribution.rs b/backend/crates/gateway-api/src/admin/plugins/distribution.rs index 2473318ba..5dc8d2d1f 100644 --- a/backend/crates/gateway-api/src/admin/plugins/distribution.rs +++ b/backend/crates/gateway-api/src/admin/plugins/distribution.rs @@ -149,10 +149,7 @@ async fn verify( .map_err(map_admin_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(VerifiedArtifactView::new( - result, - state.admin_services().plugins(), - )), + AdminEnvelope::ok(VerifiedArtifactView::new(result)), )) } @@ -169,10 +166,7 @@ async fn install( .map_err(map_admin_service_error)?; Ok(AdminResponse::new( StatusCode::CREATED, - AdminEnvelope::ok(InstallResultView::new( - result, - state.admin_services().plugins(), - )), + AdminEnvelope::ok(InstallResultView::new(result)), )) } diff --git a/backend/crates/gateway-api/src/admin/plugins/instances.rs b/backend/crates/gateway-api/src/admin/plugins/instances.rs index bcf393e9d..e0b78263d 100644 --- a/backend/crates/gateway-api/src/admin/plugins/instances.rs +++ b/backend/crates/gateway-api/src/admin/plugins/instances.rs @@ -137,7 +137,6 @@ impl From for InstanceView { trusted_process: _, configuration, secrets, - grants: _, bindings, revision, } = value.instance; diff --git a/backend/crates/gateway-api/src/admin/plugins/management.rs b/backend/crates/gateway-api/src/admin/plugins/management.rs index e165e28a2..ebe63e742 100644 --- a/backend/crates/gateway-api/src/admin/plugins/management.rs +++ b/backend/crates/gateway-api/src/admin/plugins/management.rs @@ -6,7 +6,7 @@ use axum::{ extract::{ ConnectInfo, DefaultBodyLimit, Extension, Path, Request, State, rejection::BytesRejection, }, - http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, + http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::{any, get, post}, }; @@ -24,6 +24,7 @@ use headers::{ETag, HeaderMapExt as _, IfNoneMatch}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; +use crate::middleware::headers::{decode_headers, encode_headers}; use crate::{ admin::{ AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, @@ -99,27 +100,28 @@ async fn model_responses( state .admin_services() .plugin_management() - .authorize_models(&target) + .validate_target(&target) .await .map_err(map_admin_service_error)?; let client_key_id = ClientApiKeyId::new(query.client_key_id) .map_err(|_| AdminError::bad_request("Client Key 标识不合法"))?; - let headers = model_request_headers(&parts.headers); + // 推理身份由显式 Key 决定;原始 HTTP 头仍完整交给插件,原生上游自行构造认证。 + let headers = parts.headers; let (decoded, middleware_body) = match decode_request_with_body(&body, &headers, MAXIMUM_MODEL_BODY_BYTES) { Ok(decoded) => decoded, Err(error) => { - return Ok(harden_model_response(protocol_error_response( + return Ok(protocol_error_response( StatusCode::BAD_REQUEST, error.protocol_body(), - ))); + )); } }; let service = state.openai().clone(); let execution = service.execution(); let prepared = match execution.prepare_plugin_execution(&client_key_id).await { Ok(prepared) => prepared, - Err(error) => return Ok(harden_model_response(gateway_error_response(&error))), + Err(error) => return Ok(gateway_error_response(&error)), }; let authorization: Arc = Arc::new(PluginModelsAuthorization { state, target }); @@ -139,12 +141,11 @@ async fn model_responses( headers, decoded, middleware_body, - maximum_body_bytes: MAXIMUM_MODEL_BODY_BYTES, }, Some(authorization), ) .await; - Ok(harden_model_response(response)) + Ok(response) } struct PluginModelsAuthorization { @@ -158,7 +159,7 @@ impl ResponseAuthorization for PluginModelsAuthorization { self.state .admin_services() .plugin_management() - .authorize_models(&self.target) + .validate_target(&self.target) .await .map_err(plugin_model_authorization_error) }) @@ -180,51 +181,6 @@ fn plugin_model_authorization_error(error: gateway_admin::model::AdminError) -> GatewayError::new(kind, message) } -fn model_request_headers(headers: &HeaderMap) -> HeaderMap { - let mut sanitized = HeaderMap::new(); - for name in [ - header::ACCEPT, - header::CONTENT_ENCODING, - header::CONTENT_TYPE, - header::USER_AGENT, - HeaderName::from_static("cf-connecting-ip"), - HeaderName::from_static("x-forwarded-for"), - HeaderName::from_static("x-real-ip"), - ] { - for value in headers.get_all(&name) { - sanitized.append(name.clone(), value.clone()); - } - } - sanitized -} - -fn harden_model_response(mut response: Response) -> Response { - for name in [ - header::SET_COOKIE, - HeaderName::from_static("set-cookie2"), - header::WWW_AUTHENTICATE, - header::PROXY_AUTHENTICATE, - header::AUTHORIZATION, - header::PROXY_AUTHORIZATION, - ] { - response.headers_mut().remove(name); - } - let headers = response.headers_mut(); - headers.insert( - header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'; sandbox"), - ); - headers.insert( - header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - ); - headers.insert( - header::REFERRER_POLICY, - HeaderValue::from_static("no-referrer"), - ); - response -} - async fn start_callback( auth: AdminAuth, State(state): State, @@ -271,6 +227,7 @@ async fn callback( .map(|(_, value)| value.into_owned()) .filter(|value| !value.is_empty()) .ok_or_else(|| AdminError::bad_request("插件登录回调缺少 state"))?; + let headers = encode_headers(request.headers()); if values.next().is_some() || to_bytes(request.into_body(), 0).await.is_err() { return Err(AdminError::bad_request("插件登录回调 state 重复或包含正文")); } @@ -285,6 +242,7 @@ async fn callback( path: path.path, query, content_type: None, + headers, body: Vec::new(), request_id: uuid::Uuid::now_v7().to_string(), }, @@ -340,6 +298,7 @@ async fn handle( let target = path.target(); let method = request.method().as_str().to_owned(); let query = request.uri().query().unwrap_or_default().to_owned(); + let headers = encode_headers(request.headers()); let body = to_bytes(request.into_body(), MAXIMUM_BODY_BYTES) .await .map_err(|_| { @@ -355,6 +314,7 @@ async fn handle( path: path.path, query, content_type, + headers, body: body.to_vec(), request_id: auth.context().request_id.clone(), }, @@ -452,5 +412,7 @@ fn raw_response(result: PluginManagementResponse) -> Result for RuntimeSettingsView { .map(|(provider, profile)| (provider.as_str().to_owned(), profile.into_inner())) .collect::(); Self { + config_revision: settings.config_revision.get(), openai_client_profile: provider_request_profiles.get("openai").cloned(), xai_client_profile: provider_request_profiles.get("xai").cloned(), provider_request_profiles, diff --git a/backend/crates/gateway-api/src/lib.rs b/backend/crates/gateway-api/src/lib.rs index 587ea52dd..d07bda09b 100644 --- a/backend/crates/gateway-api/src/lib.rs +++ b/backend/crates/gateway-api/src/lib.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use std::time::Duration; use axum::Router; -use axum::http::{HeaderName, HeaderValue, Method, Request, StatusCode}; +use axum::http::{HeaderName, HeaderValue, Method, Request}; use axum::routing::get; use gateway_admin::AdminServices; use gateway_core::engine::execution::ExecutionService; @@ -19,7 +19,6 @@ use serde::Deserialize; use tower_http::cors::CorsLayer; use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::services::{ServeDir, ServeFile}; -use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; use url::Url; @@ -32,6 +31,7 @@ pub mod admin; pub mod auth; mod health; mod key_usage; +mod middleware; pub mod openai; mod provider; mod session_cookie; @@ -112,11 +112,50 @@ pub enum ApiConfigError { /// 完成组装的唯一 API router。 pub struct ApiBundle { router: Router, + settings: middleware::SettingsSource, + middleware: Option, + timeout: Option, + request_id_header: HeaderName, } impl ApiBundle { + /// 组合根从请求已冻结的快照解析计划;响应流持有同一代次。 + #[must_use] + pub fn with_middleware( + mut self, + source: impl Fn( + Option<&gateway_core::routing::RuntimeSnapshot>, + ) -> Option + + Send + + Sync + + 'static, + ) -> Self { + self.middleware = Some(Arc::new(source)); + self + } + + /// 内部调用使用同一总路由;组合根持有强引用,Runtime 只保存 Weak。 + pub fn dispatcher(&self) -> Arc { + Arc::new(middleware::RouterDispatcher { + router: middleware::wrap( + self.router.clone(), + self.middleware.clone(), + self.timeout, + self.request_id_header.clone(), + self.settings.clone(), + ), + settings: self.settings.clone(), + }) + } + pub fn router(self) -> Router { - self.router + middleware::wrap( + self.router, + self.middleware, + self.timeout, + self.request_id_header, + self.settings, + ) } } @@ -133,6 +172,17 @@ pub fn initialize( config.validate().map_err(ApiError::Config)?; let request_id_header = HeaderName::from_str(&config.request_id_header) .map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?; + let settings: middleware::SettingsSource = { + let execution = execution.clone(); + let timeout_ms = config + .request_timeout_seconds + .map(|seconds| seconds.saturating_mul(1000)); + Arc::new(move || { + execution + .request_settings() + .map(|settings| settings.with_http_timeout(timeout_ms)) + }) + }; let state = ApiState { admin, openai: OpenAiService::new(execution, lifecycle), @@ -177,12 +227,6 @@ pub fn initialize( .allow_credentials(true), ); } - if let Some(seconds) = config.request_timeout_seconds { - router = router.layer(TimeoutLayer::with_status_code( - StatusCode::REQUEST_TIMEOUT, - Duration::from_secs(seconds), - )); - } // Trace 必须在 SetRequestId 内侧,span 才能捕获本服务生成的 request_id; // 默认 DEBUG span 会被 info 日志过滤器丢弃,这里显式用 info_span。 let trace_layer = TraceLayer::new_for_http().make_span_with({ @@ -211,9 +255,18 @@ pub fn initialize( let router = router .layer(PropagateRequestIdLayer::new(request_id_header.clone())) .layer(trace_layer) - .layer(SetRequestIdLayer::new(request_id_header, MakeRequestUuid)) + .layer(SetRequestIdLayer::new( + request_id_header.clone(), + MakeRequestUuid, + )) .with_state(state); - Ok(ApiBundle { router }) + Ok(ApiBundle { + settings, + router, + middleware: None, + timeout: config.request_timeout_seconds.map(Duration::from_secs), + request_id_header, + }) } async fn static_cache_control(mut response: axum::response::Response) -> axum::response::Response { diff --git a/backend/crates/gateway-api/src/middleware/headers.rs b/backend/crates/gateway-api/src/middleware/headers.rs new file mode 100644 index 000000000..f1f105694 --- /dev/null +++ b/backend/crates/gateway-api/src/middleware/headers.rs @@ -0,0 +1,27 @@ +//! HTTP HeaderMap 与插件边界值的无损转换。 + +use axum::http::{HeaderMap, HeaderName, HeaderValue}; +use bytes::Bytes; +use gateway_core::engine::middleware::{MiddlewareError, MiddlewareHeader}; + +/// 完整保留多值与非 UTF-8 header,Runtime 只转换 wire 编码。 +pub(crate) fn encode_headers(headers: &HeaderMap) -> Vec { + headers + .iter() + .map(|(name, value)| { + MiddlewareHeader::new(name.as_str(), Bytes::copy_from_slice(value.as_bytes())) + }) + .collect() +} + +pub(crate) fn decode_headers(headers: Vec) -> Result { + let mut result = HeaderMap::new(); + for header in headers { + let name = HeaderName::from_bytes(header.name().as_bytes()) + .map_err(|_| MiddlewareError::InvalidState)?; + let value = + HeaderValue::from_bytes(header.value()).map_err(|_| MiddlewareError::InvalidState)?; + result.append(name, value); + } + Ok(result) +} diff --git a/backend/crates/gateway-api/src/middleware/mod.rs b/backend/crates/gateway-api/src/middleware/mod.rs new file mode 100644 index 000000000..5cd9c287c --- /dev/null +++ b/backend/crates/gateway-api/src/middleware/mod.rs @@ -0,0 +1,359 @@ +//! 路由外层只适配 HTTP 值和正文,不解释插件协议或业务策略。 + +pub(crate) mod headers; +pub(crate) mod websocket; + +use std::{ + convert::Infallible, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; + +use axum::{ + Router, + body::Body, + http::{HeaderName, HeaderValue, Request, StatusCode}, + response::{IntoResponse as _, Response}, +}; +use gateway_core::{ + engine::middleware::FrozenMiddlewarePlan, + lifecycle::CancellationToken, + middleware::{ + compose, + http::{self as contract, Settings}, + }, +}; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use http_body_util::BodyExt as _; +use tokio::time::Instant; +use tower::ServiceExt as _; + +tokio::task_local! { static CURRENT: contract::Context; } +tokio::task_local! { static SETTINGS: Option; } + +pub(crate) fn current_settings() -> Option { + SETTINGS.try_with(Clone::clone).ok().flatten() +} + +pub(crate) fn current() -> Option { + CURRENT.try_with(Clone::clone).ok() +} + +pub(crate) async fn scope( + context: Option, + future: impl std::future::Future, +) -> T { + match context { + Some(context) => CURRENT.scope(context, future).await, + None => future.await, + } +} + +pub(crate) type SettingsSource = + Arc Option + Send + Sync>; + +pub(crate) struct RouterDispatcher { + pub(crate) router: Router, + pub(crate) settings: SettingsSource, +} + +impl contract::Dispatcher for RouterDispatcher { + fn request_settings(&self) -> Option { + (self.settings)() + } + + fn dispatch( + &self, + context: contract::Context, + mut request: contract::Request, + ) -> futures::future::BoxFuture< + 'static, + Result, + > { + let router = self.router.clone(); + let cancellation = context.cancellation.clone(); + if let Some(instance_id) = &context.plugin_instance_id { + request + .extensions_mut() + .insert(gateway_admin::model::auth::AdminRequestContext { + principal: gateway_admin::model::auth::AdminPrincipal::Plugin { + instance_id: instance_id.clone(), + }, + request_id: format!( + "plugin:{instance_id}:call:{}:request:{}", + context.call_id, context.request_id + ), + }); + } + request.extensions_mut().insert(context); + Box::pin(async move { + let response = tokio::select! { + biased; + () = cancellation.cancelled() => return Err(gateway_core::engine::middleware::MiddlewareError::Gateway(gateway_core::error::GatewayError::new(gateway_core::error::GatewayErrorKind::Cancelled, "HTTP child cancelled"))), + result = router.oneshot(request.map(Body::new)) => match result { Ok(response) => response, Err(never) => match never {} }, + }; + Ok(response.map(|body| body.map_err(|error| Box::new(error) as _).boxed_unsync())) + }) + } +} + +pub(crate) type PlanSource = Arc< + dyn Fn(Option<&gateway_core::routing::RuntimeSnapshot>) -> Option + + Send + + Sync, +>; + +pub(crate) fn wrap( + router: Router, + source: Option, + timeout: Option, + request_id_header: HeaderName, + settings: SettingsSource, +) -> Router { + // Axum 的 route layer 在匹配后运行;外层 fallback service 才能让改写后的 URI 重新匹配内层路由。 + Router::new().fallback_service(tower::service_fn(move |mut request: Request| { + let router = router.clone(); + let inherited = request.extensions().get::().is_some(); + let configuration = request.extensions_mut().get_or_insert(Settings { + timeout, + runtime: None, + }); + if configuration.runtime.is_none() && (source.is_some() || inherited) { + configuration.runtime = settings(); + } + let snapshot = configuration + .runtime + .as_ref() + .map(|settings| settings.snapshot()); + let plan = request + .extensions() + .get::() + .and_then(|context| context.plan.clone()) + .or_else(|| { + source + .as_ref() + .and_then(|source| source(snapshot.as_deref())) + }); + let request_id_header = request_id_header.clone(); + async move { Ok::<_, Infallible>(handle(router, request, plan, request_id_header).await) } + })) +} + +async fn handle( + router: Router, + mut request: Request, + plan: Option, + request_id_header: HeaderName, +) -> Response { + let started = Instant::now(); + let inherited = request.extensions_mut().remove::(); + if let Some(plan) = plan.as_ref().filter(|plan| plan.has_websocket()) { + request.extensions_mut().insert(plan.clone()); + } + if inherited.is_none() + && !plan + .as_ref() + .is_some_and(|plan| plan.has_http() || plan.has_service()) + { + let settings = request + .extensions() + .get::() + .and_then(|settings| settings.runtime.clone()); + return SETTINGS + .scope(settings, dispatch(router, request, started)) + .await; + } + let context = inherited.unwrap_or_else(|| { + let request_id = request + .headers() + .get(&request_id_header) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()); + contract::Context { + plugin_instance_id: None, + call_id: request_id.clone(), + request_id, + parent_call_id: None, + extensions: Default::default(), + cancellation: CancellationToken::new(), + plan: plan.clone(), + } + }); + let context = contract::Context { + plan: plan.clone(), + ..context + }; + if let Ok(value) = HeaderValue::from_str(&context.request_id) { + request.headers_mut().insert(request_id_header, value); + } + let guard = Arc::new(CallLifetime { + cancellation: context.cancellation.clone(), + _plan: plan.clone(), + }); + request.extensions_mut().insert(guard.clone()); + request.extensions_mut().insert(context.clone()); + let service_context = gateway_admin::service::Origin { + request_id: context.request_id.clone(), + call_id: context.call_id.clone(), + cancellation: context.cancellation.clone(), + extensions: context.extensions.clone(), + plan: gateway_admin::service::Plan::Frozen(plan.clone()), + }; + request + .extensions_mut() + .insert(Arc::new(websocket::Upgrade { + context: context.clone(), + }) as Arc); + let request = request.map(|body| body.map_err(|error| Box::new(error) as _).boxed_unsync()); + let terminal_context = context.clone(); + let next = compose(Vec::new(), move |request: contract::Request| { + Box::pin(async move { + let settings = request + .extensions() + .get::() + .and_then(|settings| settings.runtime.clone()); + let response = CURRENT + .scope( + terminal_context, + SETTINGS.scope( + settings, + gateway_admin::service::scope( + service_context, + dispatch(router, request.map(Body::new), started), + ), + ), + ) + .await; + Ok(response.map(|body| body.map_err(|error| Box::new(error) as _).boxed_unsync())) + }) + }); + let result = match plan { + Some(plan) => plan.handle_http(context, request, next).await, + None => next.run(request).await, + }; + match result { + Ok(mut response) => { + if let Some(upgraded) = response + .extensions_mut() + .remove::() + { + if response.status() != upgraded.status { + return StatusCode::BAD_GATEWAY.into_response(); + } + let Some(task) = upgraded.take_task() else { + return StatusCode::BAD_GATEWAY.into_response(); + }; + let lifetime = guard.clone(); + tokio::spawn(async move { + let cancellation = lifetime.cancellation.clone(); + let _lifetime = lifetime; + tokio::select! { + biased; + () = cancellation.cancelled() => {}, + _ = task => {}, + } + }); + } + response.map(|body| Body::new(ResponseBody::new(body, guard))) + } + Err(error) => { + let status = if error.is_rejected() { + StatusCode::FORBIDDEN + } else { + StatusCode::BAD_GATEWAY + }; + (status, axum::Json(serde_json::json!({"error": {"message": "HTTP middleware failed", "type": "middleware_error"}}))).into_response() + } + } +} + +async fn dispatch(router: Router, request: Request, started: Instant) -> Response { + let timeout = request + .extensions() + .get::() + .and_then(|settings| settings.timeout); + let dispatch = router.oneshot(request); + let result = match timeout { + Some(timeout) => { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return StatusCode::REQUEST_TIMEOUT.into_response(); + }; + match tokio::time::timeout(remaining, dispatch).await { + Ok(result) => result, + Err(_) => return StatusCode::REQUEST_TIMEOUT.into_response(), + } + } + None => dispatch.await, + }; + match result { + Ok(response) => response, + Err(never) => match never {}, + } +} + +pub(crate) struct CallLifetime { + cancellation: CancellationToken, + _plan: Option, +} + +impl Drop for CallLifetime { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + +struct ResponseBody { + body: contract::Body, + _lifetime: Arc, + cancellation: futures::future::BoxFuture<'static, ()>, + finished: bool, +} + +impl ResponseBody { + fn new(body: contract::Body, lifetime: Arc) -> Self { + let cancellation = lifetime.cancellation.clone(); + Self { + body, + _lifetime: lifetime, + cancellation: Box::pin(async move { cancellation.cancelled().await }), + finished: false, + } + } +} + +impl HttpBody for ResponseBody { + type Data = bytes::Bytes; + type Error = Box; + + fn poll_frame( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + if self.finished { + return Poll::Ready(None); + } + if self.cancellation.as_mut().poll(context).is_ready() { + self.finished = true; + self.body = contract::empty_body(); + return Poll::Ready(Some(Err(Box::new(gateway_core::error::GatewayError::new( + gateway_core::error::GatewayErrorKind::Cancelled, + "HTTP body cancelled", + ))))); + } + let frame = Pin::new(&mut self.body).poll_frame(context); + if matches!(frame, Poll::Ready(None)) { + self.finished = true; + } + frame + } + + fn is_end_stream(&self) -> bool { + self.finished || self.body.is_end_stream() + } + fn size_hint(&self) -> SizeHint { + self.body.size_hint() + } +} diff --git a/backend/crates/gateway-api/src/middleware/websocket/mod.rs b/backend/crates/gateway-api/src/middleware/websocket/mod.rs new file mode 100644 index 000000000..fe0a4ac42 --- /dev/null +++ b/backend/crates/gateway-api/src/middleware/websocket/mod.rs @@ -0,0 +1,273 @@ +//! 服务器握手与双工传输;自定义协议及多轮执行由接管会话的插件编排。 + +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{ + FromRequestParts as _, + ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, + }, +}; +use futures::{ + FutureExt as _, SinkExt as _, StreamExt as _, + future::BoxFuture, + stream::{SplitSink, SplitStream}, +}; +use gateway_core::{ + engine::middleware::{FrozenMiddlewarePlan, MiddlewareError, MiddlewareHeader}, + lifecycle::CancellationToken, + middleware::{ + compose, + http::{self as http, upgrade}, + websocket as core, + }, +}; +use http_body_util::BodyExt as _; + +pub(super) struct Upgrade { + pub(super) context: http::Context, +} + +impl upgrade::WebSocketUpgrade for Upgrade { + fn accept( + &self, + mut parts: ::http::request::Parts, + protocols: Vec, + ) -> BoxFuture<'static, Result<(http::Response, upgrade::PendingSession), MiddlewareError>> + { + let context = self.context.clone(); + Box::pin(async move { + let websocket = WebSocketUpgrade::from_request_parts(&mut parts, &()) + .await + .map_err(|_| MiddlewareError::InvalidState)?; + let headers = parts + .headers + .iter() + .map(|(name, value)| { + MiddlewareHeader::new( + name.as_str(), + bytes::Bytes::copy_from_slice(value.as_bytes()), + ) + }) + .collect::>() + .into(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let response = websocket + .protocols(protocols) + .max_message_size(usize::MAX) + .max_frame_size(usize::MAX) + .on_upgrade(move |socket| async move { + let _ = sender.send(socket); + }); + let pending = async move { + let socket = tokio::select! { + biased; + () = context.cancellation.cancelled() => return Err(Arc::new(MiddlewareError::Fault)), + socket = receiver => socket.map_err(|_| Arc::new(MiddlewareError::Fault))?, + }; + let (writer, reader) = socket.split(); + let writer = Arc::new(Writer { socket: tokio::sync::Mutex::new(writer), cancellation: context.cancellation.clone() }); + let (incoming, received) = tokio::sync::mpsc::channel(1); + let cancellation = context.cancellation.clone(); + let reader_task = tokio::spawn(read_messages(reader, incoming, cancellation)); + Ok(Arc::new(Session { + reader: tokio::sync::Mutex::new(received), reader_task: reader_task.abort_handle(), writer, + plan: context.plan, connection_id: context.request_id, headers, + cancellation: context.cancellation, + }) as Arc) + }.boxed().shared(); + Ok(( + response + .map(|body: Body| body.map_err(|error| Box::new(error) as _).boxed_unsync()), + pending, + )) + }) + } +} + +/// 原生协议与插件接管会话共用消息链;调用取消不等待插件释放 transport 锁。 +pub(crate) async fn transform( + context: core::Context, + message: core::Message, +) -> Result, MiddlewareError> { + let Some(plan) = context + .plan + .as_ref() + .filter(|plan| plan.has_websocket()) + .cloned() + else { + return Ok(Some(message)); + }; + let cancellation = context.cancellation.clone(); + let next = compose(Vec::new(), |message| Box::pin(async { Ok(Some(message)) })); + tokio::select! { + biased; + () = cancellation.cancelled() => Err(MiddlewareError::Fault), + result = plan.handle_websocket(context, message, next) => result, + } +} + +struct Session { + reader: tokio::sync::Mutex>, + reader_task: tokio::task::AbortHandle, + writer: Arc, + plan: Option, + connection_id: String, + headers: Arc<[MiddlewareHeader]>, + cancellation: CancellationToken, +} + +impl Session { + async fn transform( + &self, + message: core::Message, + direction: core::Direction, + ) -> Result, MiddlewareError> { + transform( + core::Context { + plan: self.plan.clone(), + connection_id: self.connection_id.clone(), + direction, + headers: self.headers.clone(), + cancellation: self.cancellation.clone(), + sender: self.writer.clone(), + }, + message, + ) + .await + } +} + +impl core::Session for Session { + fn receive(&self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + loop { + let message = tokio::select! { + biased; + () = self.cancellation.cancelled() => return Err(MiddlewareError::Fault), + message = async { self.reader.lock().await.recv().await } => message, + }; + let Some(message) = message else { + return Ok(None); + }; + let message = into_core(message); + // transport 锁只保护一次读取;插件可以在收包处理中主动发送。 + if let Some(message) = self.transform(message, core::Direction::Incoming).await? { + return Ok(Some(message)); + } + } + }) + } +} +impl core::Sender for Session { + fn send(&self, message: core::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + if let Some(message) = self.transform(message, core::Direction::Outgoing).await? { + self.writer.send(message).await?; + } + Ok(()) + }) + } +} + +struct Writer { + socket: tokio::sync::Mutex>, + cancellation: CancellationToken, +} +impl core::Sender for Writer { + fn send(&self, message: core::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + let message = from_core(message)?; + tokio::select! { + biased; + () = self.cancellation.cancelled() => Err(MiddlewareError::Fault), + result = async { + let mut socket = self.socket.lock().await; + let mut guard = CancelOnDrop(Some(self.cancellation.clone())); + socket.send(message).await.map_err(|_| MiddlewareError::Fault)?; + guard.0.take(); + Ok(()) + } => result, + } + }) + } +} +pub(crate) struct CancelOnDrop(pub(crate) Option); +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(cancellation) = &self.0 { + cancellation.cancel(); + } + } +} + +pub(crate) fn into_core(message: Message) -> core::Message { + let (kind, payload) = match message { + Message::Text(data) => (core::Kind::Text, data.into()), + Message::Binary(data) => (core::Kind::Binary, data), + Message::Ping(data) => (core::Kind::Ping, data), + Message::Pong(data) => (core::Kind::Pong, data), + Message::Close(Some(frame)) => ( + core::Kind::Close { + code: Some(frame.code), + }, + frame.reason.into(), + ), + Message::Close(None) => (core::Kind::Close { code: None }, bytes::Bytes::new()), + }; + core::Message { kind, payload } +} +pub(crate) fn from_core(message: core::Message) -> Result { + Ok(match message.kind { + core::Kind::Text => Message::Text( + message + .payload + .try_into() + .map_err(|_| MiddlewareError::InvalidState)?, + ), + core::Kind::Binary => Message::Binary(message.payload), + core::Kind::Ping if message.payload.len() <= 125 => Message::Ping(message.payload), + core::Kind::Pong if message.payload.len() <= 125 => Message::Pong(message.payload), + core::Kind::Close { code: Some(code) } if message.payload.len() <= 123 => { + Message::Close(Some(CloseFrame { + code, + reason: message + .payload + .try_into() + .map_err(|_| MiddlewareError::InvalidState)?, + })) + } + core::Kind::Close { code: None } if message.payload.is_empty() => Message::Close(None), + _ => return Err(MiddlewareError::InvalidState), + }) +} + +impl Drop for Session { + fn drop(&mut self) { + self.reader_task.abort(); + } +} + +async fn read_messages( + mut socket: SplitStream, + incoming: tokio::sync::mpsc::Sender, + cancellation: CancellationToken, +) { + let _guard = CancelOnDrop(Some(cancellation.clone())); + loop { + let message = tokio::select! { + biased; + () = cancellation.cancelled() => return, + message = socket.next() => message, + }; + let Some(Ok(message)) = message else { + return; + }; + tokio::select! { + biased; + () = cancellation.cancelled() => return, + result = incoming.send(message) => if result.is_err() { return; }, + } + } +} diff --git a/backend/crates/gateway-api/src/openai/auth.rs b/backend/crates/gateway-api/src/openai/auth.rs index b3d465c15..155a1d8c8 100644 --- a/backend/crates/gateway-api/src/openai/auth.rs +++ b/backend/crates/gateway-api/src/openai/auth.rs @@ -140,8 +140,12 @@ fn client_authentication_request( .ok_or(ClientApiKeyAuthError::MissingAuthorization)? .to_str() .map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?; - ClientAuthenticationRequest::new(raw.to_owned()) - .map_err(|_| ClientApiKeyAuthError::MalformedAuthorization) + let request = ClientAuthenticationRequest::new(raw.to_owned()) + .map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?; + Ok(match crate::middleware::current_settings() { + Some(settings) => request.with_settings(settings), + None => request, + }) } pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response { diff --git a/backend/crates/gateway-api/src/openai/endpoint.rs b/backend/crates/gateway-api/src/openai/endpoint.rs index 1f3113278..07caed8e1 100644 --- a/backend/crates/gateway-api/src/openai/endpoint.rs +++ b/backend/crates/gateway-api/src/openai/endpoint.rs @@ -16,10 +16,10 @@ use gateway_core::{ operation::Operation, }; +use crate::middleware::headers::encode_headers; use crate::openai::middleware::{ - ExpectedBody, HttpMiddlewareInput, PendingExecution, buffered_response, error_response, - into_http_response, invoke_http_middleware, pending_execution_response, request_headers, - request_parts, + ExpectedBody, PendingExecution, RequestInput, buffered_response, error_response, + into_http_response, invoke_request, pending_execution_response, request_parts, }; use gateway_core::event::{ProviderEvent, ProviderResponseHeader}; @@ -33,7 +33,7 @@ use super::{ pub(super) async fn provider_endpoint_response( service: OpenAiService, client: AuthenticatedClient, - input: HttpMiddlewareInput, + input: RequestInput, client_ip: Option, user_agent: Option, decode: F, @@ -48,7 +48,7 @@ where }; let request_id = prepared.request_id().clone(); let endpoint = input.endpoint.clone(); - let result = invoke_http_middleware( + let result = invoke_request( execution, prepared, input, @@ -125,7 +125,7 @@ async fn collect_raw_json_response( Ok(pending_execution_response( "openai".to_owned(), parts.status.as_u16(), - request_headers(&parts.headers), + encode_headers(&parts.headers), MiddlewareFrame::new(bytes, framing, true).with_transformed(transformed), execution, )) diff --git a/backend/crates/gateway-api/src/openai/images/http.rs b/backend/crates/gateway-api/src/openai/images/http.rs index 919257034..630c9e220 100644 --- a/backend/crates/gateway-api/src/openai/images/http.rs +++ b/backend/crates/gateway-api/src/openai/images/http.rs @@ -16,7 +16,8 @@ use gateway_core::operation::{ use serde_json::Value; use crate::ApiState; -use crate::openai::middleware::{HttpMiddlewareInput, request_headers}; +use crate::middleware::headers::encode_headers; +use crate::openai::middleware::RequestInput; use crate::openai::{ auth::{authenticate_client, client_access_error_response}, endpoint::provider_endpoint_response, @@ -83,13 +84,13 @@ async fn handle_image_request( provider_endpoint_response( service.clone(), client, - HttpMiddlewareInput { + RequestInput { endpoint: endpoint.to_owned(), protocol: OPENAI_PROTOCOL.to_owned(), operation: Some(OperationKind::GenerateImage), transport: ClientTransport::HttpJson, model_hint: None, - headers: request_headers(&headers), + headers: encode_headers(&headers), body, }, client_ip, diff --git a/backend/crates/gateway-api/src/openai/middleware/http.rs b/backend/crates/gateway-api/src/openai/middleware/http.rs index 01d6ed057..64ecc0b55 100644 --- a/backend/crates/gateway-api/src/openai/middleware/http.rs +++ b/backend/crates/gateway-api/src/openai/middleware/http.rs @@ -1,6 +1,6 @@ //! 未提交响应的 HTTP 交付;只验证协议边界,不解释或重算 Core 的业务事实。 -use super::request::{decode_headers, request_headers}; +use crate::middleware::headers::{decode_headers, encode_headers}; use crate::openai::error::{ gateway_error_from_engine, gateway_error_response, openai_error_response, }; @@ -18,7 +18,6 @@ use gateway_core::{ }, error::GatewayError, }; -use gateway_protocol::openai::response_header_is_forwardable; /// 只读目录/用量与协议错误共用的单帧响应;没有 Provider attempt 或计量副作用。 pub(crate) async fn buffered_response( @@ -37,7 +36,7 @@ pub(crate) async fn buffered_response( Ok(MiddlewareResponse::new( protocol.to_owned(), parts.status.as_u16(), - request_headers(&parts.headers), + encode_headers(&parts.headers), Box::new(BufferedBody(Some(MiddlewareFrame::new( bytes, framing, true, )))), @@ -221,29 +220,8 @@ fn response_head( if status.is_informational() { return Err(MiddlewareError::InvalidState); } - let mut result = decode_headers(headers)?; - let connection_options = result - .get_all(header::CONNECTION) - .iter() - .filter_map(|value| value.to_str().ok()) - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(str::to_ascii_lowercase) - .collect::>(); - // Content-Type 由协议 adapter 生成;其余字段在最终交付边界统一剥离认证与传输信息。 - let blocked = result - .keys() - .filter(|name| { - *name != header::CONTENT_TYPE - && !response_header_is_forwardable(name.as_str(), &connection_options) - }) - .cloned() - .collect::>(); - for name in blocked { - result.remove(name); - } - Ok((status, result)) + // 原生上游头已在协议终端处理;插件返回后的字段只做 HTTP 类型校验。 + Ok((status, decode_headers(headers)?)) } async fn fail_before_commit(mut body: Box, error: MiddlewareError) -> Response { @@ -262,19 +240,23 @@ pub(crate) fn error_response(error: MiddlewareError) -> Response { MiddlewareError::Provider(error) => { gateway_error_response(&GatewayError::from_provider(&error)) } - MiddlewareError::Rejected => openai_error_response( - StatusCode::FORBIDDEN, - "Request rejected by middleware", - "invalid_request_error", - "middleware_rejected", - ) - .into_response(), - MiddlewareError::Fault | MiddlewareError::InvalidState => openai_error_response( - StatusCode::BAD_GATEWAY, - "Request middleware failed", - "server_error", - "middleware_failed", - ) - .into_response(), + MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { + openai_error_response( + StatusCode::FORBIDDEN, + "Request rejected by middleware", + "invalid_request_error", + "middleware_rejected", + ) + .into_response() + } + MiddlewareError::Fault | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState => { + openai_error_response( + StatusCode::BAD_GATEWAY, + "Request middleware failed", + "server_error", + "middleware_failed", + ) + .into_response() + } } } diff --git a/backend/crates/gateway-api/src/openai/middleware/mod.rs b/backend/crates/gateway-api/src/openai/middleware/mod.rs index bfc2efef2..f6eda74b1 100644 --- a/backend/crates/gateway-api/src/openai/middleware/mod.rs +++ b/backend/crates/gateway-api/src/openai/middleware/mod.rs @@ -5,7 +5,5 @@ mod request; mod session; pub(crate) use http::{ExpectedBody, buffered_response, error_response, into_http_response}; -pub(crate) use request::{ - HttpMiddlewareInput, invoke_http_middleware, query_response, request_headers, request_parts, -}; +pub(crate) use request::{RequestInput, invoke_request, query_response, request_parts}; pub(crate) use session::{PendingExecution, finalize_session, pending_execution_response}; diff --git a/backend/crates/gateway-api/src/openai/middleware/request.rs b/backend/crates/gateway-api/src/openai/middleware/request.rs index 3be33c99c..5de9e3faa 100644 --- a/backend/crates/gateway-api/src/openai/middleware/request.rs +++ b/backend/crates/gateway-api/src/openai/middleware/request.rs @@ -1,10 +1,9 @@ //! 请求身份冻结、单次 next 与调用代次保活;HTTP 编码在 http 子模块中完成。 +use crate::middleware::headers::{decode_headers, encode_headers}; + use crate::openai::error::gateway_error_response; -use axum::{ - http::{HeaderMap, HeaderName, HeaderValue}, - response::Response, -}; +use axum::{http::HeaderMap, response::Response}; use bytes::Bytes; use futures::future::BoxFuture; use gateway_core::{ @@ -12,11 +11,10 @@ use gateway_core::{ execution::{ AuthenticatedClient, ClientTransport, ExecutionService, PreparedRootExecution, }, - extensions::ExtensionCallScope, middleware::{ FrozenMiddlewarePlan, MiddlewareBody, MiddlewareError, MiddlewareFrame, - MiddlewareHeader, MiddlewareMount, MiddlewareNext, MiddlewareRequest, - MiddlewareResponse, MiddlewareTarget, + MiddlewareHeader, MiddlewareMount, MiddlewareRequest, MiddlewareResponse, + MiddlewareTarget, }, }, lifecycle::CancellationToken, @@ -26,7 +24,7 @@ use std::{future::Future, sync::Arc}; use super::http::{ExpectedBody, buffered_response, error_response, into_http_response}; -pub(crate) struct HttpMiddlewareInput { +pub(crate) struct RequestInput { pub endpoint: String, pub protocol: String, pub operation: Option, @@ -36,7 +34,7 @@ pub(crate) struct HttpMiddlewareInput { pub body: Bytes, } -impl HttpMiddlewareInput { +impl RequestInput { pub(crate) fn query(endpoint: String, model_hint: Option, headers: &HeaderMap) -> Self { Self { endpoint, @@ -44,7 +42,7 @@ impl HttpMiddlewareInput { operation: None, transport: ClientTransport::HttpJson, model_hint, - headers: request_headers(headers), + headers: encode_headers(headers), body: Bytes::new(), } } @@ -54,7 +52,7 @@ impl HttpMiddlewareInput { pub(crate) async fn query_response( execution: Arc, client: AuthenticatedClient, - input: HttpMiddlewareInput, + input: RequestInput, terminal: F, ) -> Response where @@ -66,7 +64,7 @@ where Err(error) => return gateway_error_response(&error), }; let protocol = input.protocol.clone(); - let result = invoke_http_middleware( + let result = invoke_request( execution, prepared, input, @@ -86,7 +84,7 @@ where } } -pub(crate) type HttpMiddlewareTerminal = Box< +pub(crate) type RequestTerminal = Box< dyn FnOnce( PreparedRootExecution, MiddlewareRequest, @@ -94,13 +92,19 @@ pub(crate) type HttpMiddlewareTerminal = Box< + Send, >; -pub(crate) async fn invoke_http_middleware( +pub(crate) async fn invoke_request( execution: Arc, - prepared: PreparedRootExecution, - input: HttpMiddlewareInput, - terminal: HttpMiddlewareTerminal, + mut prepared: PreparedRootExecution, + input: RequestInput, + terminal: RequestTerminal, ) -> Result { - let plan = execution.middleware_plan(&prepared); + let origin = crate::middleware::current(); + if let Some(origin) = &origin { + prepared = prepared.with_extension_scope(origin.extensions.clone()); + } + let plan = origin + .and_then(|origin| origin.plan) + .or_else(|| execution.middleware_plan(&prepared)); let lifetime = RequestLifetime { cancellation: prepared.cancellation(), _plan: plan.clone(), @@ -117,10 +121,22 @@ pub(crate) async fn invoke_http_middleware( model: input.model_hint, account_id: None, }, - ExtensionCallScope::default(), + prepared.extension_scope(), ); - let request = MiddlewareRequest::new(input.protocol, input.headers, input.body); - let next = Box::new(HttpNext { prepared, terminal }); + let mut request = MiddlewareRequest::new(input.protocol, input.headers, input.body); + if plan.is_some() { + request = request.with_settings(prepared.request_settings()); + } + let next = gateway_core::middleware::compose(Vec::new(), move |request: MiddlewareRequest| { + Box::pin(async move { + if let Some(settings) = request.settings() { + prepared + .apply_settings(settings) + .map_err(MiddlewareError::Gateway)?; + } + terminal(prepared, request).await + }) + }); let response = match plan { Some(plan) => plan.handle(context, request, next).await?, None => next.run(request).await?, @@ -138,20 +154,6 @@ pub(crate) async fn invoke_http_middleware( Ok(response) } -struct HttpNext { - prepared: PreparedRootExecution, - terminal: HttpMiddlewareTerminal, -} - -impl MiddlewareNext for HttpNext { - fn run( - self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { - (self.terminal)(self.prepared, request) - } -} - struct RequestLifetime { cancellation: CancellationToken, _plan: Option, @@ -198,16 +200,6 @@ impl MiddlewareBody for RequestBody { } } -/// 保留多值与非 UTF-8 header;是否向插件公开由 Runtime 按授权投影。 -pub(crate) fn request_headers(headers: &HeaderMap) -> Vec { - headers - .iter() - .map(|(name, value)| { - MiddlewareHeader::new(name.as_str(), Bytes::copy_from_slice(value.as_bytes())) - }) - .collect() -} - /// 终点重新解码插件改写的请求;冻结认证身份不从这些 header 再次取得。 pub(crate) fn request_parts( request: MiddlewareRequest, @@ -215,15 +207,3 @@ pub(crate) fn request_parts( let (protocol, headers, body) = request.into_parts(); Ok((protocol, decode_headers(headers)?, body)) } - -pub(super) fn decode_headers(headers: Vec) -> Result { - let mut result = HeaderMap::new(); - for header in headers { - let name = HeaderName::from_bytes(header.name().as_bytes()) - .map_err(|_| MiddlewareError::InvalidState)?; - let value = - HeaderValue::from_bytes(header.value()).map_err(|_| MiddlewareError::InvalidState)?; - result.append(name, value); - } - Ok(result) -} diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index f8c9c41c0..2d78946bd 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -15,7 +15,7 @@ use serde_json::{Value, json}; use crate::{ ApiState, - openai::middleware::{self, HttpMiddlewareInput}, + openai::middleware::{self, RequestInput}, }; use super::{ @@ -46,7 +46,7 @@ pub(crate) async fn models( middleware::query_response( service.execution(), client, - HttpMiddlewareInput::query(super::router::MODELS_PATH.to_owned(), None, &headers), + RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers), move |client| models_response(state, client, query), ) .await @@ -152,7 +152,7 @@ pub(crate) async fn model_detail( middleware::query_response( service.execution(), client, - HttpMiddlewareInput::query( + RequestInput::query( format!("{}/{model_id}", super::router::MODELS_PATH), Some(model_id.clone()), &headers, diff --git a/backend/crates/gateway-api/src/openai/responses/http.rs b/backend/crates/gateway-api/src/openai/responses/http.rs index e3081a6e6..3e9b4cea0 100644 --- a/backend/crates/gateway-api/src/openai/responses/http.rs +++ b/backend/crates/gateway-api/src/openai/responses/http.rs @@ -32,10 +32,10 @@ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_wi use tokio::time::Instant; use crate::ApiState; +use crate::middleware::headers::encode_headers; use crate::openai::middleware::{ - ExpectedBody, HttpMiddlewareInput, PendingExecution, error_response, finalize_session, - into_http_response, invoke_http_middleware, pending_execution_response, request_headers, - request_parts, + ExpectedBody, PendingExecution, RequestInput, error_response, finalize_session, + into_http_response, invoke_request, pending_execution_response, request_parts, }; use crate::openai::service::OpenAiService; use crate::openai::{ @@ -98,7 +98,6 @@ pub(crate) async fn responses( headers, decoded, middleware_body, - maximum_body_bytes, }, None, ) @@ -112,7 +111,6 @@ pub(crate) struct ResponsesHttpRequest { pub headers: HeaderMap, pub decoded: DecodedResponsesRequest, pub middleware_body: Bytes, - pub maximum_body_bytes: usize, } pub(crate) async fn execute_prepared_responses( @@ -127,7 +125,6 @@ pub(crate) async fn execute_prepared_responses( headers, decoded, middleware_body, - maximum_body_bytes, } = request; let (client_ip, user_agent) = request_client_context(&headers, peer_address); let stream = decoded.metadata().stream(); @@ -146,19 +143,19 @@ pub(crate) async fn execute_prepared_responses( // 中间件正文已经由入口按上限解压;传输编码和旧长度不能与改写后的正文混用。 middleware_headers.remove(CONTENT_ENCODING); middleware_headers.remove(CONTENT_LENGTH); - let input = HttpMiddlewareInput { + let input = RequestInput { endpoint: crate::openai::router::RESPONSES_PATH.to_owned(), protocol: OPENAI_PROTOCOL.to_owned(), operation: Some(OperationKind::Generate), transport, model_hint, - headers: request_headers(&middleware_headers), + headers: encode_headers(&middleware_headers), body: middleware_body, }; let terminal_service = service.clone(); let validation = ResponseValidationFacts::default(); let terminal_validation = validation.clone(); - let middleware = invoke_http_middleware( + let middleware = invoke_request( execution, prepared, input, @@ -171,7 +168,10 @@ pub(crate) async fn execute_prepared_responses( let decoded = decode_request_with_headers( &request_body, &request_headers, - maximum_body_bytes, + prepared + .client() + .snapshot() + .responses_max_decompressed_body_bytes(), ) .map_err(|_| MiddlewareError::Rejected)? .with_client_context(client_ip, user_agent) @@ -704,7 +704,7 @@ async fn guarded_http_response( Ok(pending_execution_response( OPENAI_PROTOCOL.to_owned(), parts.status.as_u16(), - request_headers(&parts.headers), + encode_headers(&parts.headers), MiddlewareFrame::new(bytes, MiddlewareFraming::RawBytes, true), execution, )) diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/connection/middleware/mod.rs b/backend/crates/gateway-api/src/openai/responses/websocket/connection/middleware/mod.rs new file mode 100644 index 000000000..c0e855034 --- /dev/null +++ b/backend/crates/gateway-api/src/openai/responses/websocket/connection/middleware/mod.rs @@ -0,0 +1,83 @@ +//! 插件消息组合在 transport 锁之外执行,主动发送复用唯一 writer。 +use super::{ConnectionWriteError, PumpContext, write_message}; +pub(super) use crate::middleware::websocket::CancelOnDrop; +use crate::middleware::websocket::{from_core, into_core}; +use axum::extract::ws::Message; +use futures::{Sink, future::BoxFuture}; +use gateway_core::{engine::middleware::MiddlewareError, middleware::websocket as core}; +use std::sync::Arc; + +pub(super) struct Writer { + socket: tokio::sync::Mutex, + context: Arc, +} +impl Writer +where + S: Sink + Unpin + Send + 'static, + E: std::fmt::Display + Send + 'static, +{ + pub(super) fn new(socket: S, context: Arc) -> Self { + Self { + socket: tokio::sync::Mutex::new(socket), + context, + } + } + pub(super) async fn write(&self, message: Message) -> Result<(), ConnectionWriteError> { + // 预算包含排队与真实写入,取消不等待其他调用释放锁。 + tokio::select! { + biased; + () = self.context.cancellation.cancelled() => Err(ConnectionWriteError::Closed), + result = tokio::time::timeout(self.context.config.write_timeout, async { + let mut socket = self.socket.lock().await; + // 发送中断后不能继续复用可能含半写入帧的连接。 + let mut in_flight = CancelOnDrop(Some(self.context.cancellation.clone())); + let result = write_message(&mut *socket, message, &self.context).await; + if result.is_ok() { in_flight.0.take(); } + result + }) => result.unwrap_or(Err(ConnectionWriteError::Timeout { timeout: self.context.config.write_timeout })), + } + } +} +impl core::Sender for Writer +where + S: Sink + Unpin + Send + 'static, + E: std::fmt::Display + Send + 'static, +{ + fn send(&self, message: core::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + let closing = matches!(message.kind, core::Kind::Close { .. }); + self.write(from_core(message)?) + .await + .map_err(|_| MiddlewareError::Fault)?; + if closing { + self.context.cancellation.cancel(); + } + Ok(()) + }) + } +} + +pub(super) async fn transform( + message: Message, + direction: core::Direction, + sender: Arc, + context: &PumpContext, +) -> Result, MiddlewareError> { + let Some(plan) = &context.middleware else { + return Ok(Some(message)); + }; + crate::middleware::websocket::transform( + core::Context { + plan: Some(plan.clone()), + connection_id: context.connection_id.to_string(), + direction, + headers: context.headers.clone(), + cancellation: context.cancellation.clone(), + sender, + }, + into_core(message), + ) + .await? + .map(from_core) + .transpose() +} diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/connection.rs b/backend/crates/gateway-api/src/openai/responses/websocket/connection/mod.rs similarity index 67% rename from backend/crates/gateway-api/src/openai/responses/websocket/connection.rs rename to backend/crates/gateway-api/src/openai/responses/websocket/connection/mod.rs index 6f931b257..486c78d93 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/connection.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/connection/mod.rs @@ -1,5 +1,7 @@ //! 下游客户端 WebSocket 的单 owner pump 与有界收发边界。 +mod middleware; + use std::{ collections::VecDeque, fmt, @@ -12,7 +14,11 @@ use std::{ use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code}; use futures::{Sink, SinkExt, Stream, StreamExt}; -use gateway_core::lifecycle::CancellationToken; +use gateway_core::{ + engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader}, + lifecycle::CancellationToken, + middleware::websocket as contract, +}; use thiserror::Error; use tokio::{ sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot}, @@ -145,6 +151,7 @@ pub enum PumpExitReason { InboundOverload, ServerClose, PumpStopped, + MiddlewareFailed, } impl PumpExitReason { @@ -161,6 +168,7 @@ impl PumpExitReason { Self::InboundOverload => "inbound_overload", Self::ServerClose => "server_close", Self::PumpStopped => "pump_stopped", + Self::MiddlewareFailed => "middleware_failed", } } } @@ -176,10 +184,17 @@ pub enum ConnectionWriteError { Transport { message: String }, } +/// 只有实际 transport 写入才记为 Written;插件丢弃消息不伪造写入成功。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WriteOutcome { + Written, + Suppressed, +} + struct ConnectionCommand { message: Message, context: WriteContext, - acknowledged: oneshot::Sender>, + acknowledged: oneshot::Sender>, } #[derive(Default)] @@ -187,7 +202,6 @@ struct ConnectionStats { command_queue_high_water: AtomicUsize, command_backpressure_count: AtomicU64, ping_received_count: AtomicU64, - pong_written_count: AtomicU64, ping_written_count: AtomicU64, pong_received_count: AtomicU64, last_read_ms: AtomicU64, @@ -243,12 +257,16 @@ impl ResponsesWebSocketConnection { socket: WebSocket, connection_id: String, cancellation: CancellationToken, + middleware: Option, + headers: Arc<[MiddlewareHeader]>, ) -> Self { - spawn_connection( + spawn_with_middleware( socket, Arc::::from(connection_id), cancellation, ConnectionConfig::PRODUCTION, + middleware, + headers, ) } @@ -337,7 +355,7 @@ impl ResponsesWebSocketConnection { &mut self, payload: String, context: WriteContext, - ) -> Result<(), ConnectionWriteError> { + ) -> Result { self.send(Message::Text(payload.into()), context).await } @@ -392,7 +410,7 @@ impl ResponsesWebSocketConnection { &mut self, message: Message, context: WriteContext, - ) -> Result<(), ConnectionWriteError> { + ) -> Result { let Some(commands) = self.commands.as_ref().cloned() else { return Err(ConnectionWriteError::Closed); }; @@ -423,19 +441,20 @@ impl ResponsesWebSocketConnection { }; let duration = started_at.elapsed(); match &result { - Ok(()) if phase.is_milestone() => tracing::info!( + Ok(WriteOutcome::Written) if phase.is_milestone() => tracing::info!( websocket_connection_id = %self.connection_id, request_id = request_id.as_deref().unwrap_or(""), frame_phase = phase.as_str(), write_duration_ms = duration.as_millis(), "Responses WebSocket frame write succeeded" ), - Ok(()) => tracing::debug!( + Ok(outcome) => tracing::debug!( websocket_connection_id = %self.connection_id, request_id = request_id.as_deref().unwrap_or(""), frame_phase = phase.as_str(), write_duration_ms = duration.as_millis(), - "Responses WebSocket frame write succeeded" + ?outcome, + "Responses WebSocket frame processed" ), Err(error) => tracing::info!( websocket_connection_id = %self.connection_id, @@ -487,7 +506,6 @@ impl ResponsesWebSocketConnection { .command_backpressure_count .load(Ordering::Relaxed), ping_received_count = self.stats.ping_received_count.load(Ordering::Relaxed), - pong_written_count = self.stats.pong_written_count.load(Ordering::Relaxed), ping_written_count = self.stats.ping_written_count.load(Ordering::Relaxed), pong_received_count = self.stats.pong_received_count.load(Ordering::Relaxed), read_idle_ms, @@ -506,13 +524,35 @@ impl Drop for ResponsesWebSocketConnection { } } -/// 为一个 socket 启动单 owner pump,并返回协调层句柄。 +/// 为自动处理 Ping/Pong 的 WebSocket transport 启动单 owner pump。 pub fn spawn_connection( socket: S, connection_id: Arc, cancellation: CancellationToken, config: ConnectionConfig, ) -> ResponsesWebSocketConnection +where + S: Stream> + Sink + Unpin + Send + 'static, + E: fmt::Display + Send + 'static, +{ + spawn_with_middleware( + socket, + connection_id, + cancellation, + config, + None, + Arc::from([]), + ) +} + +fn spawn_with_middleware( + socket: S, + connection_id: Arc, + cancellation: CancellationToken, + config: ConnectionConfig, + middleware: Option, + headers: Arc<[MiddlewareHeader]>, +) -> ResponsesWebSocketConnection where S: Stream> + Sink + Unpin + Send + 'static, E: fmt::Display + Send + 'static, @@ -534,6 +574,8 @@ where Arc::clone(&expired), Arc::clone(&stats), config, + middleware, + headers, )); ResponsesWebSocketConnection { connection_id, @@ -552,8 +594,8 @@ where #[expect(clippy::too_many_arguments)] async fn run_pump( - mut socket: S, - mut commands: mpsc::Receiver, + socket: S, + commands: mpsc::Receiver, incoming: mpsc::Sender, exited: oneshot::Sender, connection_id: Arc, @@ -562,150 +604,206 @@ async fn run_pump( expired: Arc, stats: Arc, config: ConnectionConfig, + middleware: Option, + headers: Arc<[MiddlewareHeader]>, ) where - S: Stream> + Sink + Unpin, + S: Stream> + Sink + Unpin + Send + 'static, + E: fmt::Display + Send + 'static, +{ + let context = Arc::new(PumpContext { + connection_id, + cancellation: cancellation.child_token(), + opened_at, + expired, + stats, + config, + middleware, + headers, + }); + let _lifetime = middleware::CancelOnDrop(Some(context.cancellation.clone())); + let (writer, mut reader) = socket.split(); + let writer = Arc::new(middleware::Writer::new(writer, context.clone())); + let sender: Arc = writer.clone(); + // 同一个受管任务同时驱动两侧;等待写入不能阻塞入站控制、关闭或取消。 + let reason = tokio::select! { + biased; + reason = read_connection(&mut reader, &incoming, sender.clone(), &context) => reason, + reason = write_connection(writer.clone(), commands, &context) => reason, + }; + context.cancellation.cancel(); + let _ = exited.send(reason); + tracing::debug!(websocket_connection_id = %context.connection_id, pump_exit_reason = reason.as_str(), "Responses WebSocket pump exited"); +} + +struct PumpContext { + middleware: Option, + headers: Arc<[MiddlewareHeader]>, + connection_id: Arc, + cancellation: CancellationToken, + opened_at: Instant, + expired: Arc, + stats: Arc, + config: ConnectionConfig, +} + +async fn read_connection( + socket: &mut S, + incoming: &mpsc::Sender, + sender: Arc, + context: &PumpContext, +) -> PumpExitReason +where + S: Stream> + Unpin, E: fmt::Display, { let event_slots = Arc::new(Semaphore::new(INBOUND_EVENT_BUFFER)); - let deadline = tokio::time::sleep_until(opened_at + config.max_age); + let deadline = tokio::time::sleep_until(context.opened_at + context.config.max_age); tokio::pin!(deadline); let mut deadline_elapsed = false; + loop { + tokio::select! { + biased; + () = context.cancellation.cancelled() => return PumpExitReason::LifecycleShutdown, + () = &mut deadline, if !deadline_elapsed => { + deadline_elapsed = true; + context.expired.store(true, Ordering::Release); + // 到期只阻止下一轮,当前响应仍由协调层按原有合同收尾。 + match emit_incoming(incoming, &event_slots, ConnectionEvent::Expired) { + Ok(()) | Err(PumpExitReason::InboundOverload) => {} + Err(reason) => return reason, + } + } + message = socket.next() => { + if matches!(&message, Some(Ok(_))) { context.stats.record_read(context.opened_at); } + let message = match message { + Some(Ok(message)) => match middleware::transform(message, contract::Direction::Incoming, sender.clone(), context).await { + Ok(Some(message)) => Some(Ok(message)), + Ok(None) => continue, + Err(_) => return PumpExitReason::MiddlewareFailed, + }, + other => other, + }; + let event = match message { + Some(Ok(Message::Text(payload))) => Some(ConnectionEvent::Text(payload.to_string())), + Some(Ok(Message::Binary(_))) => Some(ConnectionEvent::Binary), + Some(Ok(Message::Ping(_))) => { + // Axum/tungstenite 在继续读取时自动刷新 Pong,不能再手工发送一份。 + context.stats.ping_received_count.fetch_add(1, Ordering::Relaxed); + None + } + Some(Ok(Message::Pong(_))) => { + context.stats.pong_received_count.fetch_add(1, Ordering::Relaxed); + None + } + Some(Ok(Message::Close(_))) => return PumpExitReason::ClientClose, + Some(Err(error)) => { + tracing::info!(websocket_connection_id = %context.connection_id, error = %error, "Responses WebSocket receive failed"); + return PumpExitReason::ReadError; + } + None => return PumpExitReason::PeerEof, + }; + if let Some(event) = event && let Err(reason) = emit_incoming(incoming, &event_slots, event) { return reason; } + } + } + } +} + +async fn write_connection( + writer: Arc>, + mut commands: mpsc::Receiver, + context: &PumpContext, +) -> PumpExitReason +where + S: Sink + Unpin + Send + 'static, + E: fmt::Display + Send + 'static, +{ let mut heartbeat = tokio::time::interval_at( - opened_at + DOWNSTREAM_PING_INTERVAL, + context.opened_at + DOWNSTREAM_PING_INTERVAL, DOWNSTREAM_PING_INTERVAL, ); heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut ping_sequence = 0_u64; - let reason = loop { - tokio::select! { + loop { + let message = tokio::select! { biased; - () = cancellation.cancelled() => break PumpExitReason::LifecycleShutdown, - () = &mut deadline, if !deadline_elapsed => { - deadline_elapsed = true; - expired.store(true, Ordering::Release); - // 满队列本身已能唤醒空闲协调层;到期标志仍阻止启动下一轮。 - // 不能因无法追加 Expired 通知而中断正在收尾的响应。 - match emit_incoming(&incoming, &event_slots, ConnectionEvent::Expired) { - Ok(()) | Err(PumpExitReason::InboundOverload) => {} - Err(reason) => break reason, - } - } + () = context.cancellation.cancelled() => return PumpExitReason::LifecycleShutdown, command = commands.recv() => { - let Some(command) = command else { - break PumpExitReason::CoordinatorDropped; - }; - let ConnectionCommand { - message, - context, - acknowledged, - } = command; + let Some(command) = command else { return PumpExitReason::CoordinatorDropped; }; + let ConnectionCommand { message, context: write_context, acknowledged } = command; let closing = matches!(message, Message::Close(_)); - let result = socket.send(message).await.map_err(|error| { - let message = error.to_string(); - tracing::debug!( - websocket_connection_id = %connection_id, - request_id = context.request_id.as_deref().unwrap_or(""), - frame_phase = context.phase.as_str(), - error = %message, - "Responses WebSocket pump transport write failed" - ); - ConnectionWriteError::Transport { message } - }); - let failed = result.is_err(); - if !failed { - stats.record_write(opened_at); + let result = match middleware::transform(message, contract::Direction::Outgoing, writer.clone(), context).await { + Ok(Some(message)) => writer.write(message).await.map(|()| WriteOutcome::Written), + Ok(None) => Ok(WriteOutcome::Suppressed), + Err(error) => Err(ConnectionWriteError::Transport { message: error.to_string() }), + }; + let reason = result.as_ref().err().map(|error| write_exit_reason(error, context)); + if let Err(error) = &result { + tracing::debug!(websocket_connection_id = %context.connection_id, request_id = write_context.request_id.as_deref().unwrap_or(""), frame_phase = write_context.phase.as_str(), error = %error, "Responses WebSocket pump transport write failed"); } let _ = acknowledged.send(result); - if failed { - break PumpExitReason::WriteError; - } - if closing { - break PumpExitReason::ServerClose; - } + if let Some(reason) = reason { return reason; } + if closing { return PumpExitReason::ServerClose; } + continue; } - // 官方 Codex WsStream 在独立 pump 中回复 Pong,且不将控制帧交给业务流。 - // 这里只为下游链路保活,不设置 Pong deadline,也不延长业务请求预算。 _ = heartbeat.tick() => { ping_sequence = ping_sequence.wrapping_add(1); - let ping = Message::Ping(ping_sequence.to_be_bytes().to_vec().into()); - let result = tokio::select! { - biased; - () = cancellation.cancelled() => break PumpExitReason::LifecycleShutdown, - result = timeout(config.write_timeout, socket.send(ping)) => result, - }; - match result { - Ok(Ok(())) => { - stats.ping_written_count.fetch_add(1, Ordering::Relaxed); - stats.record_write(opened_at); - } - Ok(Err(error)) => { - tracing::info!( - websocket_connection_id = %connection_id, - error = %error, - "Responses WebSocket Ping write failed" - ); - break PumpExitReason::WriteError; - } - Err(_) => break PumpExitReason::WriteTimeout, - } + Message::Ping(ping_sequence.to_be_bytes().to_vec().into()) } - message = socket.next() => { - if matches!(&message, Some(Ok(_))) { - stats.record_read(opened_at); - } - match message { - Some(Ok(Message::Text(payload))) => { - if let Err(reason) = emit_incoming(&incoming, &event_slots, ConnectionEvent::Text(payload.to_string())) { - break reason; - } - } - Some(Ok(Message::Binary(_))) => { - if let Err(reason) = emit_incoming(&incoming, &event_slots, ConnectionEvent::Binary) { - break reason; - } - } - Some(Ok(Message::Ping(payload))) => { - stats.ping_received_count.fetch_add(1, Ordering::Relaxed); - match timeout(config.write_timeout, socket.send(Message::Pong(payload))).await { - Ok(Ok(())) => { - stats.pong_written_count.fetch_add(1, Ordering::Relaxed); - stats.record_write(opened_at); - } - Ok(Err(error)) => { - tracing::info!( - websocket_connection_id = %connection_id, - error = %error, - "Responses WebSocket Pong write failed" - ); - break PumpExitReason::WriteError; - } - Err(_) => break PumpExitReason::WriteTimeout, - } - } - Some(Ok(Message::Pong(_))) => { - stats.pong_received_count.fetch_add(1, Ordering::Relaxed); - } - Some(Ok(Message::Close(_))) => break PumpExitReason::ClientClose, - Some(Err(error)) => { - tracing::info!( - websocket_connection_id = %connection_id, - error = %error, - "Responses WebSocket receive failed" - ); - break PumpExitReason::ReadError; - } - None => break PumpExitReason::PeerEof, - } - }, + }; + let message = match middleware::transform( + message, + contract::Direction::Outgoing, + writer.clone(), + context, + ) + .await + { + Ok(Some(message)) => message, + Ok(None) => continue, + Err(_) => return PumpExitReason::MiddlewareFailed, + }; + if let Err(error) = writer.write(message).await { + tracing::info!(websocket_connection_id = %context.connection_id, error = %error, "Responses WebSocket control write failed"); + return write_exit_reason(&error, context); } - }; - // 退出不能排在待执行请求之后,也不能因业务队列已满而丢失。 - let _ = exited.send(reason); - tracing::debug!( - websocket_connection_id = %connection_id, - pump_exit_reason = reason.as_str(), - "Responses WebSocket pump exited" - ); + context + .stats + .ping_written_count + .fetch_add(1, Ordering::Relaxed); + } +} + +async fn write_message( + socket: &mut S, + message: Message, + context: &PumpContext, +) -> Result<(), ConnectionWriteError> +where + S: Sink + Unpin, + E: fmt::Display, +{ + tokio::select! { + biased; + () = context.cancellation.cancelled() => Err(ConnectionWriteError::Closed), + result = timeout(context.config.write_timeout, socket.send(message)) => { + match result { + Ok(Ok(())) => { context.stats.record_write(context.opened_at); Ok(()) } + Ok(Err(error)) => Err(ConnectionWriteError::Transport { message: error.to_string() }), + Err(_) => Err(ConnectionWriteError::Timeout { timeout: context.config.write_timeout }), + } + } + } +} + +fn write_exit_reason(error: &ConnectionWriteError, context: &PumpContext) -> PumpExitReason { + match error { + ConnectionWriteError::Timeout { .. } => PumpExitReason::WriteTimeout, + ConnectionWriteError::Transport { .. } => PumpExitReason::WriteError, + ConnectionWriteError::Closed if context.cancellation.is_cancelled() => { + PumpExitReason::LifecycleShutdown + } + ConnectionWriteError::Closed => PumpExitReason::PumpStopped, + } } fn emit_incoming( diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs b/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs index 2397746c9..a45eef2fc 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs @@ -18,7 +18,7 @@ use gateway_core::event::ProviderResponseHeader; use gateway_core::operation::ProviderSessionState; use crate::openai::error::{gateway_error_contract, gateway_error_from_engine}; -use crate::openai::middleware::PendingExecution; +use crate::openai::middleware::{PendingExecution, pending_execution_response}; use crate::openai::responses::validation::{ResponseValidationFacts, ResponsesDeliveryValidator}; use super::{ @@ -92,17 +92,17 @@ pub(super) async fn execution_response( let headers = body.response_headers().to_vec(); let status = middleware_error_status(&error); let _ = body.record_client_status(status).await; - Box::new(body).close().await; if let MiddlewareError::Engine(error) = error { let response_headers = headers .iter() .map(|header| MiddlewareHeader::new(header.name(), header.value().clone())) .collect(); - return Ok(MiddlewareResponse::new( + // 失败也要保留会话到洋葱链返回,否则释放会话会取消仍在等待 next 的插件。 + return Ok(pending_execution_response( "openai".to_owned(), status, response_headers, - Box::new(SingleFrameBody(Some(MiddlewareFrame::new( + MiddlewareFrame::new( Bytes::from(initial_engine_error_event( &error, request_id.as_ref(), @@ -110,9 +110,11 @@ pub(super) async fn execution_response( )), MiddlewareFraming::JsonDocument, true, - )))), + ), + body.execution, )); } + Box::new(body).close().await; return Err(error); } let headers = body @@ -128,18 +130,6 @@ pub(super) async fn execution_response( )) } -struct SingleFrameBody(Option); - -impl MiddlewareBody for SingleFrameBody { - fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { - Box::pin(async { Ok(self.0.take()) }) - } - - fn close(self: Box) -> BoxFuture<'static, ()> { - Box::pin(async move { drop(self) }) - } -} - pub(super) async fn forward_response( connection: &mut ResponsesWebSocketConnection, response: MiddlewareResponse, @@ -272,15 +262,17 @@ pub(super) async fn forward_response( (true, true) => FramePhase::Terminal, (true, false) => FramePhase::Data, }; - if connection + match connection .send_text(message, WriteContext::request(&request_id, phase)) .await - .is_err() { - detach_body(body); - return ForwardOutcome::Disconnect; + Ok(super::connection::WriteOutcome::Written) => first_frame_written = true, + Ok(super::connection::WriteOutcome::Suppressed) => {} + Err(_) => { + detach_body(body); + return ForwardOutcome::Disconnect; + } } - first_frame_written = true; terminal_seen = terminal; } } @@ -369,14 +361,18 @@ fn middleware_gateway_error(error: MiddlewareError) -> GatewayError { MiddlewareError::Gateway(error) => error, MiddlewareError::Engine(error) => gateway_error_from_engine(&error), MiddlewareError::Provider(error) => GatewayError::from_provider(&error), - MiddlewareError::Rejected => GatewayError::new( - GatewayErrorKind::PolicyDenied, - "request middleware rejected the request", - ), - MiddlewareError::Fault | MiddlewareError::InvalidState => GatewayError::new( - GatewayErrorKind::Internal, - "request middleware returned an invalid response", - ), + MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { + GatewayError::new( + GatewayErrorKind::PolicyDenied, + "request middleware rejected the request", + ) + } + MiddlewareError::Fault | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState => { + GatewayError::new( + GatewayErrorKind::Internal, + "request middleware returned an invalid response", + ) + } } } @@ -393,8 +389,10 @@ fn middleware_error_status(error: &MiddlewareError) -> u16 { .0 .as_u16() } - MiddlewareError::Rejected => StatusCode::FORBIDDEN.as_u16(), - MiddlewareError::Fault | MiddlewareError::InvalidState => { + MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { + StatusCode::FORBIDDEN.as_u16() + } + MiddlewareError::Fault | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState => { StatusCode::INTERNAL_SERVER_ERROR.as_u16() } } diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs index 8563c2fe2..c407e33a2 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs @@ -23,18 +23,16 @@ use gateway_core::{ diagnostics::{TraceContext, diagnostic_json}, engine::{ execution::{AuthenticatedClient, ClientTransport}, - middleware::MiddlewareError, + middleware::{FrozenMiddlewarePlan, MiddlewareError}, }, lifecycle::{ConnectionGuard, ConnectionLifecycle}, operation::OperationKind, }; +use crate::middleware::headers::encode_headers; use crate::{ ApiState, - openai::middleware::{ - HttpMiddlewareInput, invoke_http_middleware, request_headers as middleware_request_headers, - request_parts, - }, + openai::middleware::{RequestInput, invoke_request, request_parts}, openai::{ auth::{authenticate_client, client_access_error_response}, error::runtime_unavailable_response, @@ -61,6 +59,8 @@ const CONNECTION_LIMIT_CLOSE_REASON: &str = "Responses websocket connection limi pub(crate) async fn responses_websocket( State(state): State, connect_info: Option>>, + middleware: Option>, + lifetime: Option>>, headers: HeaderMap, websocket: WebSocketUpgrade, ) -> Response { @@ -81,6 +81,8 @@ pub(crate) async fn responses_websocket( user_agent, request_headers, headers, + middleware.map(|Extension(plan)| plan), + lifetime.map(|Extension(lifetime)| lifetime), ) } @@ -97,6 +99,7 @@ impl ResponsesWebSocketAdapter { Self { service } } + #[expect(clippy::too_many_arguments)] fn upgrade_with_client_context( &self, websocket: WebSocketUpgrade, @@ -105,6 +108,8 @@ impl ResponsesWebSocketAdapter { user_agent: Option, request_headers: OpenAiRequestHeaders, raw_headers: HeaderMap, + middleware: Option, + lifetime: Option>, ) -> Response { let connection_guard = match self.service.try_register_connection() { Ok(guard) => guard, @@ -128,14 +133,17 @@ impl ResponsesWebSocketAdapter { raw_headers, lifecycle: self.service.lifecycle(), connection_guard, + middleware, }; + let origin = crate::middleware::current(); websocket // 覆盖 axum/tungstenite 的私有 64 MiB message 与 16 MiB frame 默认值。 // Responses JSON 的协议可接受性由上游决定,代理不另设 wire 长度上限。 .max_message_size(usize::MAX) .max_frame_size(usize::MAX) .on_upgrade(move |socket| async move { - serve_responses_websocket(socket, session).await; + let _lifetime = lifetime; + crate::middleware::scope(origin, serve_responses_websocket(socket, session)).await; }) } } @@ -150,6 +158,7 @@ struct ResponsesWebSocketSession { raw_headers: HeaderMap, lifecycle: Arc, connection_guard: Box, + middleware: Option, } async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocketSession) { @@ -163,6 +172,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke raw_headers, lifecycle, connection_guard, + middleware, } = session; tracing::info!( websocket_connection_id = %connection_id, @@ -172,7 +182,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke let cancellation = lifecycle.cancellation(); let request_headers = request_headers.with_downstream_websocket_connection_id(connection_id.clone()); - let mut connection = ResponsesWebSocketConnection::new(socket, connection_id, cancellation); + let mut connection = ResponsesWebSocketConnection::new( + socket, + connection_id, + cancellation, + middleware, + encode_headers(&raw_headers).into(), + ); let mut request_count = 0_u64; let mut replay = ConnectionReplaySnapshot::default(); @@ -235,7 +251,27 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke break; } let execution = service.execution(); - let prepared = match execution.prepare_execution(client.clone()).await { + let preparation = async { + // 握手的显式改写可继承,Key 策略与宿主设置仍在每轮执行前刷新。 + let mut client = client.clone(); + if let Some(settings) = client.request_settings() { + let current = execution.request_settings().ok_or_else(|| { + gateway_core::error::GatewayError::new( + gateway_core::error::GatewayErrorKind::Internal, + "runtime snapshot is unavailable", + ) + })?; + let rebased = settings.rebase(current.snapshot()).map_err(|_| { + gateway_core::error::GatewayError::new( + gateway_core::error::GatewayErrorKind::InvalidRequest, + "request settings are invalid", + ) + })?; + client = client.with_request_settings(rebased); + } + execution.prepare_execution(client).await + }; + let prepared = match preparation.await { Ok(prepared) => prepared, Err(error) => { trace_rejected_request( @@ -257,13 +293,13 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke let response_control = prepared.response_control(); let capture = new_replay_capture(); let validation = ResponseValidationFacts::default(); - let input = HttpMiddlewareInput { + let input = RequestInput { endpoint: crate::openai::router::RESPONSES_PATH.to_owned(), protocol: "openai".to_owned(), operation: Some(OperationKind::Generate), transport: ClientTransport::WebSocket, model_hint: Some(decoded.metadata().requested_model().to_owned()), - headers: middleware_request_headers(&raw_headers), + headers: encode_headers(&raw_headers), body: Bytes::from(payload.clone()), }; let service_for_terminal = service.clone(); @@ -273,7 +309,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke let request_id_for_terminal = Arc::clone(&request_id); let capture_for_terminal = Arc::clone(&capture); let validation_for_terminal = validation.clone(); - let invoke = invoke_http_middleware( + let invoke = invoke_request( execution, prepared, input, diff --git a/backend/crates/gateway-api/src/openai/search/http.rs b/backend/crates/gateway-api/src/openai/search/http.rs index 055cae166..769e13a0e 100644 --- a/backend/crates/gateway-api/src/openai/search/http.rs +++ b/backend/crates/gateway-api/src/openai/search/http.rs @@ -13,7 +13,8 @@ use gateway_core::error::{GatewayError, GatewayErrorKind}; use gateway_core::operation::{Operation, OperationKind, RawJsonPayload, StandaloneSearchRequest}; use crate::ApiState; -use crate::openai::middleware::{HttpMiddlewareInput, request_headers}; +use crate::middleware::headers::encode_headers; +use crate::openai::middleware::RequestInput; use crate::openai::{ auth::{authenticate_client, client_access_error_response}, endpoint::provider_endpoint_response, @@ -42,13 +43,13 @@ pub(crate) async fn standalone_search( provider_endpoint_response( service.clone(), client, - HttpMiddlewareInput { + RequestInput { endpoint: SEARCH_PATH.to_owned(), protocol: OPENAI_PROTOCOL.to_owned(), operation: Some(OperationKind::Search), transport: ClientTransport::HttpJson, model_hint: None, - headers: request_headers(&headers), + headers: encode_headers(&headers), body, }, client_ip, diff --git a/backend/crates/gateway-api/src/openai/usage.rs b/backend/crates/gateway-api/src/openai/usage.rs index 2af6e94c1..ee7ebabb7 100644 --- a/backend/crates/gateway-api/src/openai/usage.rs +++ b/backend/crates/gateway-api/src/openai/usage.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use crate::{ ApiState, auth::SessionState as _, - openai::middleware::{self as plugin_middleware, HttpMiddlewareInput}, + openai::middleware::{self as plugin_middleware, RequestInput}, }; use super::{ @@ -48,7 +48,7 @@ async fn usage( plugin_middleware::query_response( state.openai().execution(), client, - HttpMiddlewareInput::query(super::router::USAGE_PATH.to_owned(), None, &headers), + RequestInput::query(super::router::USAGE_PATH.to_owned(), None, &headers), move |client| usage_response(state, client, uri), ) .await diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index a8610fc78..55a5b80f3 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -184,6 +184,7 @@ impl AdminTestFixture { ClientConfig::default(), stores, gateway_admin::AdminRuntimePorts { + service_middleware: std::sync::Arc::new(|| None), plugin_preparation: plugin_ports.clone(), plugin_management: plugin_ports.clone(), published_snapshot: published_snapshot.clone(), @@ -535,6 +536,13 @@ impl SettingsStore for MemorySettingsStore { _: &MutationContext, ) -> AdminStoreResult { let mut settings = self.settings.lock().expect("settings"); + if command.expected_revision != settings.config_revision { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Conflict, + "runtime settings", + "settings revision changed", + )); + } let mut request_profiles = settings.request_profiles.clone(); for (provider, profile) in command.request_profile_updates { if let Some(profile) = profile { diff --git a/backend/crates/gateway-api/tests/admin/plugins/management.rs b/backend/crates/gateway-api/tests/admin/plugins/management.rs index 33fa81e20..a11a0eec9 100644 --- a/backend/crates/gateway-api/tests/admin/plugins/management.rs +++ b/backend/crates/gateway-api/tests/admin/plugins/management.rs @@ -120,10 +120,19 @@ impl MiddlewarePlan for ModelMiddleware { &self, _: MiddlewareContext, request: MiddlewareRequest, - _: Box, + _: MiddlewareNext, ) -> BoxFuture<'static, Result> { - for header in request.headers() { - assert!(!["cookie", "authorization", "x-api-key"].contains(&header.name())); + for (name, value) in [ + ("cookie", "cpr_session=management-session".to_owned()), + ("authorization", "Bearer admin-fixture".to_owned()), + ("x-api-key", format!("admin-{}", "a".repeat(64))), + ] { + assert!( + request + .headers() + .iter() + .any(|header| { header.name() == name && header.value() == value.as_bytes() }) + ); } assert!( request @@ -158,6 +167,7 @@ impl MiddlewarePlan for ModelMiddleware { vec![ MiddlewareHeader::new("set-cookie", "fixture=value".into()), MiddlewareHeader::new("www-authenticate", "Bearer".into()), + MiddlewareHeader::new("x-plugin-response", "complete".into()), ], Box::new(ModelBody { frame: Some(MiddlewareFrame::new(bytes.into(), framing, terminal)), @@ -204,7 +214,61 @@ fn model_request(stream: bool) -> Request { } #[tokio::test] -async fn model_bridge_executes_and_protects_request_and_response_headers() { +async fn management_calls_and_callbacks_preserve_headers_and_override_defaults() { + let fixture = published_fixture().await; + let target = format!("plugin-instance-diagnostic/{}/7", "a".repeat(64)); + for uri in [ + format!("/api/admin/plugins/extensions/{target}/api/echo"), + format!("/plugins/callbacks/{target}/oauth?state=nonce"), + ] { + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot( + Request::get(uri) + .header("cookie", "cpr_session=management-session") + .header("authorization", "Bearer management-fixture") + .header( + "x-plugin-binary", + axum::http::HeaderValue::from_bytes(&[0xff]).unwrap(), + ) + .header("set-cookie", "first=1") + .header("set-cookie", "second=2") + .header("content-security-policy", "default-src 'self'") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["cookie"], + "cpr_session=management-session" + ); + assert_eq!( + response.headers()["authorization"], + "Bearer management-fixture" + ); + assert_eq!(response.headers()["x-plugin-binary"].as_bytes(), &[0xff]); + assert_eq!( + response + .headers() + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(), + ["first=1", "second=2"] + ); + assert_eq!( + response.headers()["content-security-policy"], + "default-src 'self'" + ); + } +} + +#[tokio::test] +async fn model_bridge_preserves_complete_request_and_response_headers() { let fixture = published_fixture().await; let execution = ModelExecution::new(ModelReply::Complete); let response = crate::openai::api_router_with_admin_and_execution(fixture.services, execution) @@ -212,10 +276,9 @@ async fn model_bridge_executes_and_protects_request_and_response_headers() { .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key("set-cookie")); - assert!(!response.headers().contains_key("www-authenticate")); - assert_eq!(response.headers()["x-content-type-options"], "nosniff"); - assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + assert_eq!(response.headers()["set-cookie"], "fixture=value"); + assert_eq!(response.headers()["www-authenticate"], "Bearer"); + assert_eq!(response.headers()["x-plugin-response"], "complete"); assert_eq!(response.headers()["cache-control"], "no-store"); let body = axum::body::to_bytes(response.into_body(), 4096) .await @@ -290,6 +353,7 @@ pub(super) fn resource_fixture( path: &str, ) -> gateway_admin::model::plugins::management::PluginManagementResponse { gateway_admin::model::plugins::management::PluginManagementResponse { + headers: Vec::new(), status: if path == "missing" { 404 } else { 200 }, content_type: if path.ends_with(".css") { "text/css" @@ -303,11 +367,10 @@ pub(super) fn resource_fixture( async fn published_fixture() -> AdminTestFixture { use gateway_core::{ - account::{AccountSelectionPolicy, RotationStrategy}, routing::{ConfigRevision, RuntimeSnapshot}, runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, }; - use std::{num::NonZeroU32, sync::Arc, time::Duration}; + use std::sync::Arc; struct Lease; impl ExtensionSetLease for Lease { fn is_ready(&self) -> bool { @@ -322,10 +385,13 @@ async fn published_fixture() -> AdminTestFixture { fixture.published_snapshot.publish( RuntimeSnapshot::new( ConfigRevision::new(7).unwrap(), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - NonZeroU32::new(1).unwrap(), - Duration::ZERO, + gateway_core::settings::SettingsValues::new( + 1, + 0, + "smart", + Default::default(), + None, + None, ), vec![], vec![], @@ -510,6 +576,8 @@ async fn model_responses_requires_the_exact_authorized_target_before_execution() "/api/admin/plugins/extensions/{target}/models/responses?{query}" )) .header("cookie", "cpr_session=management-session") + .header("authorization", "Bearer admin-fixture") + .header("x-api-key", format!("admin-{}", "a".repeat(64))) .header("content-type", "application/json") .header("x-request-id", "management-model-request") .body(Body::from( diff --git a/backend/crates/gateway-api/tests/admin/plugins/mod.rs b/backend/crates/gateway-api/tests/admin/plugins/mod.rs index 78b84775b..fbd4b1d17 100644 --- a/backend/crates/gateway-api/tests/admin/plugins/mod.rs +++ b/backend/crates/gateway-api/tests/admin/plugins/mod.rs @@ -61,7 +61,7 @@ fn artifact_metadata_wire_exposes_v2_identity_and_contributions() { output_formats: vec!["openai".into()], }, )]), - requested_permissions: vec![], + configuration_schema: serde_json::json!({}), secret_fields: vec![], state_namespaces: vec![], @@ -414,13 +414,15 @@ async fn artifact_acceptance_returns_the_flat_install_contract() { assert_eq!(result["defaultInstanceId"], serde_json::Value::Null); assert_eq!(result["configurationRequired"], false); assert_eq!(result["artifact"]["metadata"]["sha256"], "a".repeat(64)); - assert_eq!( - result["artifact"]["metadata"]["permissionDescriptions"][0]["permission"], - "models" + assert!( + result["artifact"]["metadata"] + .get("permissionDescriptions") + .is_none() ); - assert_eq!( - result["artifact"]["metadata"]["permissionDescriptions"][0]["label"], - "当前宿主标签" + assert!( + result["artifact"]["metadata"] + .get("requestedPermissions") + .is_none() ); assert!(result["artifact"]["acceptedAt"].is_string()); assert!(result.get("mutation").is_none()); @@ -540,7 +542,7 @@ pub(super) struct TestPluginPorts { #[async_trait] impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts { - async fn authorize_models( + async fn validate_target( &self, _: &gateway_core::runtime::extensions::ExtensionSetReference, _: &gateway_admin::model::plugins::management::PluginManagementTarget, @@ -563,10 +565,12 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor _: &gateway_core::runtime::extensions::ExtensionSetReference, _: &gateway_admin::model::plugins::management::PluginManagementTarget, _: &str, - _: gateway_admin::model::plugins::management::PluginManagementRequest, + request: gateway_admin::model::plugins::management::PluginManagementRequest, ) -> Result { - Ok(management::resource_fixture("ui/index.html")) + let mut response = management::resource_fixture("ui/index.html"); + response.headers = request.headers; + Ok(response) } async fn views( &self, @@ -592,10 +596,12 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor &self, _: &gateway_core::runtime::extensions::ExtensionSetReference, _: &gateway_admin::model::plugins::management::PluginManagementTarget, - _: gateway_admin::model::plugins::management::PluginManagementRequest, + request: gateway_admin::model::plugins::management::PluginManagementRequest, ) -> Result { - Ok(management::resource_fixture("api/echo")) + let mut response = management::resource_fixture("api/echo"); + response.headers = request.headers; + Ok(response) } } @@ -677,7 +683,7 @@ fn icon_artifact(digest: &str) -> InspectedPluginArtifact { icon: (digest.as_bytes()[0] == b'f') .then(|| PluginArtifactIcon::Path("assets/icon.png".into())), contributes: BTreeMap::new(), - requested_permissions: Vec::new(), + configuration_schema: serde_json::json!({}), secret_fields: Vec::new(), state_namespaces: Vec::new(), @@ -687,8 +693,7 @@ fn icon_artifact(digest: &str) -> InspectedPluginArtifact { } fn accepted_artifact() -> InstalledPluginArtifact { - let mut metadata = icon_artifact(&"a".repeat(64)).metadata; - metadata.requested_permissions = vec!["models".into()]; + let metadata = icon_artifact(&"a".repeat(64)).metadata; InstalledPluginArtifact { metadata, source: PluginSource::Upload, @@ -802,7 +807,7 @@ impl PluginStore for TestPluginPorts { trusted_process: true, configuration: serde_json::json!({}), secrets: BTreeMap::new(), - grants: Vec::new(), + bindings: Vec::new(), revision: Revision::new(7).unwrap(), }] @@ -895,22 +900,6 @@ impl PluginStore for TestPluginPorts { #[async_trait] impl PluginPackageInspector for TestPluginPorts { - fn permission_descriptions( - &self, - permissions: &[String], - ) -> Vec { - permissions - .iter() - .map( - |permission| gateway_admin::model::plugins::PluginPermissionDescription { - permission: permission.clone(), - label: "当前宿主标签".into(), - description: "当前宿主说明".into(), - }, - ) - .collect() - } - async fn inspect( &self, _: Arc<[u8]>, diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index b72bfd0d4..2a283dafa 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -37,6 +37,7 @@ async fn response_json(response: axum::response::Response) -> Value { fn update_body() -> Value { json!({ + "configRevision": 7, "requestLocationEnabled": false, "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { @@ -247,6 +248,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { assert_eq!( value, json!({ + "configRevision": 7, "providerRequestProfiles": {}, "openaiClientProfile": null, "xaiClientProfile": null, @@ -420,7 +422,7 @@ async fn settings_post_should_replace_global_model_mappings() { .expect("settings update response"); let data = response_json(response).await["data"].clone(); - assert!(data.get("configRevision").is_none()); + assert_eq!(data["configRevision"], 8); assert_eq!(data["modelMappings"]["gpt-5.4"], "gpt-5.5"); assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5"); } @@ -608,12 +610,12 @@ async fn request_location_should_normalize_toggle_and_round_trip() { .oneshot(request(Method::GET, "/api/admin/settings", None)) .await .unwrap(); - assert_eq!( - response_json(response).await["data"]["requestLocation"], - expected - ); + let data = response_json(response).await["data"].clone(); + assert_eq!(data["requestLocation"], expected); + let mut revision = data["configRevision"].clone(); for enabled in [false, true] { let mut body = update_body(); + body["configRevision"] = revision.clone(); body["requestLocationEnabled"] = json!(enabled); body["requestLocation"] = expected.clone(); let response = app(fixture.state()) @@ -630,6 +632,7 @@ async fn request_location_should_normalize_toggle_and_round_trip() { .await .unwrap(); let data = response_json(response).await["data"].clone(); + revision = data["configRevision"].clone(); assert_eq!(data["requestLocationEnabled"], json!(enabled)); assert_eq!(data["requestLocation"], expected); } @@ -1215,3 +1218,53 @@ async fn pricing_endpoints_require_administrator_authentication() { ); } } + +#[tokio::test] +async fn settings_update_rejects_a_stale_version_without_replacing_the_saved_value() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let router = app(fixture.state()); + let first = router + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(update_body()), + )) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::OK); + let first = response_json(first).await["data"].clone(); + let mut stale = update_body(); + stale["refreshMarginSeconds"] = json!(9999); + let conflict = router + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(stale.clone()), + )) + .await + .unwrap(); + assert_eq!(conflict.status(), StatusCode::CONFLICT); + let current = router + .clone() + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(); + assert_eq!(response_json(current).await["data"], first); + stale["configRevision"] = first["configRevision"].clone(); + let retry = router + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(stale), + )) + .await + .unwrap(); + assert_eq!(retry.status(), StatusCode::OK); + assert_eq!( + response_json(retry).await["data"]["refreshMarginSeconds"], + 9999 + ); +} diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 987c6fca1..10be61f1b 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -66,6 +66,9 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/key_usage/query.rs", "src/key_usage/presenter.rs", "src/lib.rs", + "src/middleware/headers.rs", + "src/middleware/mod.rs", + "src/middleware/websocket/mod.rs", "src/openai/middleware/http.rs", "src/openai/middleware/mod.rs", "src/openai/middleware/request.rs", @@ -83,7 +86,8 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/openai/responses/request.rs", "src/openai/responses/response.rs", "src/openai/responses/validation.rs", - "src/openai/responses/websocket/connection.rs", + "src/openai/responses/websocket/connection/mod.rs", + "src/openai/responses/websocket/connection/middleware/mod.rs", "src/openai/responses/websocket/forward.rs", "src/openai/responses/websocket/mod.rs", "src/openai/responses/websocket/protocol.rs", @@ -131,6 +135,8 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/key_usage/mod.rs", "tests/key_usage/fixtures.rs", "tests/main.rs", + "tests/middleware/mod.rs", + "tests/middleware/websocket/mod.rs", "tests/openai/middleware/mod.rs", "tests/openai/auth.rs", "tests/openai/error.rs", @@ -141,7 +147,8 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/openai/responses/http.rs", "tests/openai/responses/mod.rs", "tests/openai/responses/request.rs", - "tests/openai/responses/websocket/connection.rs", + "tests/openai/responses/websocket/connection/mod.rs", + "tests/openai/responses/websocket/connection/middleware/mod.rs", "tests/openai/responses/websocket/forward.rs", "tests/openai/responses/websocket/interrupt.rs", "tests/openai/responses/websocket/mod.rs", diff --git a/backend/crates/gateway-api/tests/main.rs b/backend/crates/gateway-api/tests/main.rs index b13aac930..1cb8b8725 100644 --- a/backend/crates/gateway-api/tests/main.rs +++ b/backend/crates/gateway-api/tests/main.rs @@ -3,6 +3,7 @@ mod architecture; mod auth; mod health; mod key_usage; +mod middleware; mod openai; mod provider; mod support; diff --git a/backend/crates/gateway-api/tests/middleware/mod.rs b/backend/crates/gateway-api/tests/middleware/mod.rs new file mode 100644 index 000000000..9f6f31204 --- /dev/null +++ b/backend/crates/gateway-api/tests/middleware/mod.rs @@ -0,0 +1,620 @@ +mod websocket; + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use bytes::Bytes; +use futures::{future::BoxFuture, stream}; +use gateway_core::{ + engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareContext, MiddlewareError, MiddlewareNext, MiddlewarePlan, + MiddlewareRequest, MiddlewareResponse, + }, + lifecycle::CancellationToken, + middleware::http as contract, + runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, +}; +use http_body::Frame; +use http_body_util::{BodyExt as _, StreamBody}; +use tower::ServiceExt as _; + +#[derive(Clone, Copy, Debug)] +enum Mode { + Rewrite, + Upgrade { + reject: bool, + idle: bool, + block_messages: bool, + }, + Service, + Echo, + Slow { + clear_timeout: bool, + }, +} + +#[derive(Debug)] +struct Plan { + mode: Mode, + calls: Arc, + cancellation: Arc>>, + message_lifetime: Arc<(tokio::sync::Notify, tokio::sync::Notify)>, +} +struct Lease; +impl ExtensionSetLease for Lease { + fn is_ready(&self) -> bool { + true + } +} + +impl MiddlewarePlan for Plan { + fn has_http(&self) -> bool { + !matches!(self.mode, Mode::Service) + } + fn has_service(&self) -> bool { + matches!(self.mode, Mode::Service) + } + fn has_websocket(&self) -> bool { + matches!(self.mode, Mode::Upgrade { .. }) + } + fn handle_websocket( + &self, + context: gateway_core::middleware::websocket::Context, + message: gateway_core::middleware::websocket::Message, + next: gateway_core::middleware::websocket::Next, + ) -> BoxFuture< + 'static, + Result, MiddlewareError>, + > { + assert!(context.plan.is_some()); + self.calls.fetch_add(1, Ordering::SeqCst); + if !matches!( + self.mode, + Mode::Upgrade { + block_messages: true, + .. + } + ) { + return next.run(message); + } + let lifetime = self.message_lifetime.clone(); + Box::pin(async move { + struct NotifyOnDrop(Arc<(tokio::sync::Notify, tokio::sync::Notify)>); + impl Drop for NotifyOnDrop { + fn drop(&mut self) { + self.0.1.notify_one(); + } + } + let _guard = NotifyOnDrop(lifetime.clone()); + lifetime.0.notify_one(); + std::future::pending().await + }) + } + fn handle_service( + &self, + context: gateway_core::middleware::service::Context, + input: serde_json::Value, + next: gateway_core::middleware::service::Next, + ) -> BoxFuture<'static, Result> + { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(context.operation, "settings.load"); + assert_eq!(context.request_id, "service-request"); + assert_eq!(context.parent_call_id.as_deref(), Some("service-request")); + assert!(input.is_null()); + Box::pin(async move { + let mut result = next.run(input).await?; + result["request_interval_ms"] = serde_json::json!(4321); + Ok(result) + }) + } + fn handle_http( + &self, + context: contract::Context, + mut request: contract::Request, + next: contract::Next, + ) -> BoxFuture<'static, Result> { + if matches!(self.mode, Mode::Service) { + return next.run(request); + } + self.calls.fetch_add(1, Ordering::SeqCst); + *self.cancellation.lock().unwrap() = Some(context.cancellation); + let mode = self.mode; + Box::pin(async move { + match mode { + Mode::Service => unreachable!("service-only plan bypasses HTTP layers"), + Mode::Upgrade { reject, idle, .. } => { + let upgrade = request + .extensions_mut() + .remove::>() + .unwrap(); + let (parts, _) = request.into_parts(); + let (mut response, session) = + upgrade.accept(parts, vec!["test-session".into()]).await?; + let status = response.status(); + response.extensions_mut().insert(contract::upgrade::Upgraded::new(status, Box::pin(async move { + let session = session.await.map_err(|_| MiddlewareError::Fault)?; + if idle { return std::future::pending().await; } + use gateway_core::middleware::websocket::{Kind, Message}; + let receive = session.receive(); + tokio::pin!(receive); + tokio::select! { + message = &mut receive => panic!("must await the client: {}", message.is_ok()), + () = tokio::time::sleep(Duration::from_millis(30)) => {}, + } + session.send(Message { kind: Kind::Text, payload: Bytes::from_static(b"ready") }).await?; + let message = receive.await?.unwrap(); + session.send(message).await?; + session.send(Message { kind: Kind::Close { code: Some(1000) }, payload: Bytes::from_static(b"done") }).await + }))); + if reject { + *response.status_mut() = StatusCode::FORBIDDEN; + } + Ok(response) + } + Mode::Rewrite => { + assert_eq!(request.uri(), "/unrecognized/client/path?raw=%2f%FF"); + assert_eq!(request.headers()["authorization"], "Bearer fixture-secret"); + assert_eq!(request.headers().get_all("x-repeat").iter().count(), 2); + *request.uri_mut() = "/healthz".parse().unwrap(); + *request.method_mut() = http::Method::GET; + next.run(request).await + } + Mode::Echo => { + let (parts, body) = request.into_parts(); + let mut response = contract::Response::new(body); + *response.status_mut() = StatusCode::CREATED; + *response.headers_mut() = parts.headers; + response.extensions_mut().insert(123_u32); + Ok(response) + } + Mode::Slow { clear_timeout } => { + assert_eq!( + request + .extensions() + .get::() + .unwrap() + .timeout, + Some(Duration::from_secs(1)) + ); + if clear_timeout { + request + .extensions_mut() + .get_mut::() + .unwrap() + .timeout = None; + } + tokio::time::sleep(Duration::from_secs(2)).await; + next.run(request).await + } + } + }) + } + fn handle( + &self, + _: MiddlewareContext, + _: MiddlewareRequest, + _: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + Box::pin(async { panic!("HTTP 入口不能伪装为已认证模型请求") }) + } +} + +async fn router(mode: Mode, timeout: Option) -> (axum::Router, Arc) { + let admin = crate::admin::AdminTestFixture::new().await; + admin.auth.insert_session("service-fixture"); + let plan = Arc::new(Plan { + mode, + calls: Arc::default(), + cancellation: Arc::default(), + message_lifetime: Arc::default(), + }); + let frozen = FrozenMiddlewarePlan::new( + plan.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("http-test".into()).unwrap(), + Arc::new(Lease), + ), + ); + let router = crate::openai::api_bundle( + admin.services, + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: timeout, + request_id_header: "x-request-id".into(), + }, + ) + .with_middleware(move |_| Some(frozen.clone())) + .router(); + (router, plan) +} + +#[tokio::test] +async fn rewrites_unknown_path_before_routing_without_reading_request_body() { + let (router, plan) = router(Mode::Rewrite, None).await; + let reads = Arc::new(AtomicUsize::new(0)); + let body_reads = reads.clone(); + let body = Body::from_stream(stream::poll_fn(move |_| { + body_reads.fetch_add(1, Ordering::SeqCst); + std::task::Poll::Ready(Some(Ok::<_, std::io::Error>(Bytes::from_static( + b"must remain lazy", + )))) + })); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/unrecognized/client/path?raw=%2f%FF") + .header("authorization", "Bearer fixture-secret") + .header("x-repeat", "one") + .header("x-repeat", "two") + .body(body) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(plan.calls.load(Ordering::SeqCst), 1); + assert_eq!(reads.load(Ordering::SeqCst), 0); + assert!( + !plan + .cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); + drop(response); + assert!( + plan.cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); +} + +#[tokio::test] +async fn short_circuit_preserves_binary_headers_trailers_extensions_and_backpressure() { + let (router, _) = router(Mode::Echo, None).await; + let mut trailers = http::HeaderMap::new(); + trailers.append("x-end", "one".parse().unwrap()); + trailers.append("x-end", "two".parse().unwrap()); + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let mut frames = vec![ + Frame::data(Bytes::from_static(b"first")), + Frame::data(Bytes::from_static(b"second")), + Frame::trailers(trailers), + ] + .into_iter(); + let body = Body::new(StreamBody::new(stream::poll_fn(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + std::task::Poll::Ready(frames.next().map(Ok::<_, std::io::Error>)) + }))); + let response = router + .oneshot( + Request::builder() + .uri("/arbitrary/plugin/route") + .header("x-binary", http::HeaderValue::from_bytes(&[0xFF]).unwrap()) + .body(body) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["x-binary"].as_bytes(), &[0xFF]); + assert_eq!(response.extensions().get::(), Some(&123)); + assert_eq!(reads.load(Ordering::SeqCst), 0); + let mut body = response.into_body(); + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + "first" + ); + assert_eq!(reads.load(Ordering::SeqCst), 1); + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + "second" + ); + let end = body + .frame() + .await + .unwrap() + .unwrap() + .into_trailers() + .unwrap(); + assert_eq!(end.get_all("x-end").iter().count(), 2); + assert!(body.frame().await.is_none()); +} + +#[tokio::test(start_paused = true)] +async fn host_timeout_is_a_baseline_and_plugin_override_is_consumed_by_terminal() { + for clear_timeout in [false, true] { + let (router, _) = router(Mode::Slow { clear_timeout }, Some(1)).await; + let response = router + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + if clear_timeout { + StatusCode::NO_CONTENT + } else { + StatusCode::REQUEST_TIMEOUT + } + ); + } +} + +#[tokio::test] +async fn admin_http_does_not_invoke_service_middleware_for_native_business_methods() { + let (router, plan) = router(Mode::Service, None).await; + let response = router + .oneshot( + Request::builder() + .uri("/api/admin/settings") + .header("cookie", "cpr_session=service-fixture") + .header("x-request-id", "service-request") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + let result: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_ne!(result["data"]["requestIntervalMs"], 4321); + assert_eq!(plan.calls.load(Ordering::SeqCst), 0); +} + +#[derive(Debug)] +struct Inspect(Arc>>); +impl gateway_core::engine::middleware::MiddlewarePlan for Inspect { + fn has_http(&self) -> bool { + true + } + fn handle_http( + &self, + context: contract::Context, + mut request: contract::Request, + next: contract::Next, + ) -> BoxFuture<'static, Result> { + self.0.lock().unwrap().push(context.clone()); + assert_eq!( + request + .extensions() + .get::() + .unwrap() + .call_id, + context.call_id + ); + assert!(context.extensions.contains("origin-plugin")); + assert_eq!(request.headers()["x-request-id"], "parent-request"); + assert_eq!( + request + .extensions() + .get::() + .unwrap() + .timeout, + None + ); + *request.uri_mut() = "/healthz".parse().unwrap(); + next.run(request) + } + fn handle( + &self, + _: MiddlewareContext, + _: MiddlewareRequest, + _: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + Box::pin(async { panic!("health request has no model boundary") }) + } +} + +#[tokio::test] +async fn internal_dispatch_uses_the_same_router_and_the_frozen_parent_context() { + let admin = crate::admin::AdminTestFixture::new().await; + let calls = Arc::new(Mutex::new(Vec::new())); + let frozen = FrozenMiddlewarePlan::new( + Arc::new(Inspect(calls.clone())), + ExtensionSetReference::new( + ExtensionSetId::new("frozen-child".into()).unwrap(), + Arc::new(Lease), + ), + ); + let bundle = crate::openai::api_bundle( + admin.services, + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: Some(60), + request_id_header: "x-request-id".into(), + }, + ); + let dispatcher = bundle.dispatcher(); + let parent = CancellationToken::new(); + let child = parent.child_token(); + let context = contract::Context { + plugin_instance_id: None, + request_id: "parent-request".into(), + call_id: "child-call".into(), + parent_call_id: Some("parent-call".into()), + extensions: Default::default(), + plan: Some(frozen), + cancellation: child.clone(), + }; + let context = contract::Context { + extensions: context + .extensions + .extending("origin-plugin".into()) + .unwrap(), + ..context + }; + let mut request = http::Request::builder() + .uri("/plugin/unknown/route") + .body(contract::empty_body()) + .unwrap(); + request.extensions_mut().insert(contract::Settings { + timeout: None, + runtime: None, + }); + let response = dispatcher.dispatch(context, request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(!child.is_cancelled()); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!(body.is_empty()); + assert!(child.is_cancelled()); + assert!(!parent.is_cancelled()); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].call_id, "child-call"); + assert_eq!(calls[0].parent_call_id.as_deref(), Some("parent-call")); +} + +#[tokio::test] +async fn parent_cancellation_wakes_a_pending_child_response_body() { + let admin = crate::admin::AdminTestFixture::new().await; + let plan = Arc::new(Plan { + mode: Mode::Echo, + calls: Arc::default(), + cancellation: Arc::default(), + message_lifetime: Arc::default(), + }); + let frozen = FrozenMiddlewarePlan::new( + plan, + ExtensionSetReference::new( + ExtensionSetId::new("cancel-child".into()).unwrap(), + Arc::new(Lease), + ), + ); + let bundle = crate::openai::api_bundle( + admin.services, + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: None, + request_id_header: "x-request-id".into(), + }, + ); + let parent = CancellationToken::new(); + let context = contract::Context { + plugin_instance_id: None, + request_id: "parent".into(), + call_id: "child".into(), + parent_call_id: Some("parent".into()), + extensions: Default::default(), + plan: Some(frozen), + cancellation: parent.child_token(), + }; + let entered = Arc::new(tokio::sync::Notify::new()); + let observed = entered.clone(); + let body = StreamBody::new(stream::poll_fn(move |_| { + observed.notify_one(); + std::task::Poll::Pending::, std::io::Error>>> + })) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let response = bundle + .dispatcher() + .dispatch(context, contract::Request::new(body)) + .await + .unwrap(); + let task = tokio::spawn(async move { response.into_body().collect().await }); + entered.notified().await; + parent.cancel(); + assert!( + tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .is_err() + ); +} + +#[tokio::test] +async fn plugin_http_dispatch_uses_host_identity_without_client_credentials() { + let admin = crate::admin::AdminTestFixture::new().await; + let bundle = crate::openai::api_bundle( + admin.services, + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: None, + request_id_header: "x-request-id".into(), + }, + ); + let dispatcher = bundle.dispatcher(); + let context = contract::Context { + plugin_instance_id: Some("installed-plugin".into()), + request_id: "model-parent".into(), + call_id: "management-child".into(), + parent_call_id: Some("parent-call".into()), + extensions: Default::default(), + plan: None, + cancellation: CancellationToken::new(), + }; + let response = dispatcher + .dispatch( + context.clone(), + http::Request::builder() + .uri("/api/admin/settings") + .body(contract::empty_body()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let data: serde_json::Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert!(data["data"].is_object()); + let mutation = contract::Context { + cancellation: CancellationToken::new(), + call_id: "mutation-child".into(), + ..context + }; + let response = dispatcher + .dispatch( + mutation, + http::Request::builder() + .method("POST") + .uri("/api/admin/settings/admin-api-key/delete") + .body(contract::empty_body()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + response.into_body().collect().await.unwrap(); + let response = bundle + .router() + .oneshot( + Request::builder() + .uri("/api/admin/settings") + .header("x-plugin-instance-id", "installed-plugin") + .header("x-parent-call-id", "parent-call") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} diff --git a/backend/crates/gateway-api/tests/middleware/websocket/mod.rs b/backend/crates/gateway-api/tests/middleware/websocket/mod.rs new file mode 100644 index 000000000..fc20e4ca1 --- /dev/null +++ b/backend/crates/gateway-api/tests/middleware/websocket/mod.rs @@ -0,0 +1,160 @@ +use super::*; +use futures::{SinkExt as _, StreamExt as _}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Message, client::IntoClientRequest as _}, +}; + +#[tokio::test] +async fn custom_upgrade_keeps_the_call_alive_and_allows_send_during_receive() { + let (router, plan) = router( + Mode::Upgrade { + reject: false, + idle: false, + block_messages: false, + }, + None, + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + tokio::time::timeout(Duration::from_secs(5), async { + let mut request = format!("ws://{address}/plugin-owned/session") + .into_client_request() + .unwrap(); + request.headers_mut().insert( + "sec-websocket-protocol", + "other, test-session".parse().unwrap(), + ); + let (mut socket, response) = connect_async(request).await.unwrap(); + assert_eq!(response.headers()["sec-websocket-protocol"], "test-session"); + assert_eq!( + socket.next().await.unwrap().unwrap(), + Message::Text("ready".into()) + ); + let cancellation = plan.cancellation.lock().unwrap().clone().unwrap(); + assert!(!cancellation.is_cancelled(), "101 正文结束不能取消会话"); + socket + .send(Message::Binary(vec![0, 255, 128].into())) + .await + .unwrap(); + assert_eq!( + socket.next().await.unwrap().unwrap(), + Message::Binary(vec![0, 255, 128].into()) + ); + let Message::Close(Some(close)) = socket.next().await.unwrap().unwrap() else { + panic!("close frame expected"); + }; + assert_eq!(close.reason, "done"); + cancellation.cancelled().await; + assert_eq!( + plan.calls.load(Ordering::SeqCst), + 5, + "HTTP 握手及四次消息收发各组合一次" + ); + }) + .await + .unwrap(); + server.abort(); +} + +#[tokio::test] +async fn replacing_upgrade_status_drops_the_session_before_handshake() { + let (router, plan) = router( + Mode::Upgrade { + reject: true, + idle: false, + block_messages: false, + }, + None, + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let result = tokio::time::timeout( + Duration::from_secs(5), + connect_async(format!("ws://{address}/plugin-owned/session")), + ) + .await + .unwrap(); + assert!( + matches!(result, Err(tokio_tungstenite::tungstenite::Error::Http(response)) if response.status() == StatusCode::BAD_GATEWAY) + ); + assert!( + plan.cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); + server.abort(); +} + +#[tokio::test] +async fn disconnect_cancels_an_idle_session_without_a_plugin_receive_call() { + let (router, plan) = router( + Mode::Upgrade { + reject: false, + idle: true, + block_messages: false, + }, + None, + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (socket, _) = connect_async(format!("ws://{address}/idle")).await.unwrap(); + let cancellation = plan.cancellation.lock().unwrap().clone().unwrap(); + assert!(!cancellation.is_cancelled()); + drop(socket); + tokio::time::timeout(Duration::from_secs(5), cancellation.cancelled()) + .await + .unwrap(); + server.abort(); +} + +#[tokio::test] +async fn cancelling_custom_session_drops_a_blocked_message_middleware() { + let (router, plan) = router( + Mode::Upgrade { + reject: false, + idle: false, + block_messages: true, + }, + None, + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (socket, _) = connect_async(format!("ws://{address}/blocked")) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), plan.message_lifetime.0.notified()) + .await + .unwrap(); + let cancellation = plan.cancellation.lock().unwrap().clone().unwrap(); + cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(5), plan.message_lifetime.1.notified()) + .await + .unwrap(); + assert_eq!( + plan.calls.load(Ordering::SeqCst), + 2, + "取消后不会重新进入消息链" + ); + drop(socket); + server.abort(); +} diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 731e8fe28..74619694f 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -409,3 +409,165 @@ fn chatgpt_remote_suffix_created_by_header_truncation_should_not_skip_the_gate() assert_eq!(client.kind(), CodexClientKind::Desktop); assert!(client.version().is_none()); } + +#[tokio::test] +async fn http_settings_freeze_before_plan_resolution_and_apply_before_admission() { + use gateway_core::{ + engine::middleware::*, middleware::http as contract, runtime::extensions::*, + }; + struct Lease; + impl ExtensionSetLease for Lease { + fn is_ready(&self) -> bool { + true + } + } + #[derive(Debug)] + struct Entry; + impl MiddlewarePlan for Entry { + fn has_http(&self) -> bool { + true + } + fn handle_http( + &self, + _: contract::Context, + mut request: contract::Request, + next: contract::Next, + ) -> BoxFuture<'static, Result> { + let settings = request + .extensions_mut() + .get_mut::() + .unwrap(); + let context = settings.runtime.as_ref().unwrap(); + let mut values = serde_json::to_value(context.values()).unwrap(); + assert_eq!(values["responses_max_decompressed_body_bytes"], 32); + assert_eq!(values["min_codex_cli_version"], "0.40.0"); + values["responses_max_decompressed_body_bytes"] = serde_json::json!(1024); + values["min_codex_cli_version"] = serde_json::Value::Null; + settings.runtime = Some( + context + .replace(serde_json::from_value(values).unwrap(), "entry") + .unwrap(), + ); + next.run(request) + } + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + next.run(request) + } + } + let snapshot = super::snapshot("sk_entry", "openai"); + let settings = snapshot + .settings() + .clone() + .with_min_codex_client_versions(super::CodexClientMinVersions::new( + None, + Some(super::CodexClientVersion::parse("0.40.0").unwrap()), + )) + .with_responses_max_decompressed_body_bytes(32); + let snapshot = snapshot.with_settings(&settings).unwrap(); + let snapshots = super::RuntimeSnapshotHandle::new(snapshot); + let execution = Arc::new(super::DefaultExecutionService::new( + snapshots.clone(), + Arc::new(super::UnusedExecutionStore), + super::ProviderRegistry::default(), + Arc::new(super::UnusedAdmissions), + Arc::new(super::UnusedContinuation), + Arc::new(super::IgnoredClientApiKeyUsage), + )); + let admin = crate::admin::AdminTestFixture::new().await; + let bundle = gateway_api::initialize( + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: vec![], + request_timeout_seconds: None, + request_id_header: "x-request-id".into(), + }, + execution, + admin.services, + vec![], + Arc::new(super::EmptyWorkerHealth), + Arc::new(super::TestLifecycle::default()), + ) + .unwrap(); + let baseline = bundle.dispatcher(); + let plan = FrozenMiddlewarePlan::new( + Arc::new(Entry), + ExtensionSetReference::new( + ExtensionSetId::new("entry-settings".into()).unwrap(), + Arc::new(Lease), + ), + ); + let publisher = snapshots.clone(); + let router = bundle + .with_middleware(move |snapshot| { + let snapshot = snapshot.unwrap(); + assert_eq!(snapshot.responses_max_decompressed_body_bytes(), 32); + publisher.publish( + snapshot + .with_settings( + &snapshot + .settings() + .clone() + .with_responses_max_decompressed_body_bytes(64), + ) + .unwrap(), + ); + Some(plan.clone()) + }) + .router(); + let invalid = format!("{}!", " ".repeat(100)); + let compressed = zstd::stream::encode_all(invalid.as_bytes(), 0).unwrap(); + let request = || { + axum::http::Request::post("/v1/responses") + .header(AUTHORIZATION, "Bearer sk_entry") + .header("user-agent", "codex_cli_rs/0.1.0") + .header("content-encoding", "zstd") + .body(axum::body::Body::from(compressed.clone())) + .unwrap() + }; + let response = router.oneshot(request()).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + body["error"]["code"], "invalid_json", + "the expanded body must reach the decoder" + ); + let context = contract::Context { + request_id: "baseline".into(), + call_id: "baseline".into(), + parent_call_id: None, + plugin_instance_id: None, + plan: None, + extensions: Default::default(), + cancellation: Default::default(), + }; + use http_body_util::BodyExt as _; + let response = baseline + .dispatch( + context, + request().map(|body| body.map_err(|error| Box::new(error) as _).boxed_unsync()), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UPGRADE_REQUIRED, + "request changes must not alter the published baseline" + ); + assert_eq!( + snapshots + .acquire() + .unwrap() + .responses_max_decompressed_body_bytes(), + 64 + ); +} diff --git a/backend/crates/gateway-api/tests/openai/error.rs b/backend/crates/gateway-api/tests/openai/error.rs index f7044548b..456615b51 100644 --- a/backend/crates/gateway-api/tests/openai/error.rs +++ b/backend/crates/gateway-api/tests/openai/error.rs @@ -703,8 +703,12 @@ mod model_routing { ), ] { for stream in [false, true] { - let snapshot = crate::openai::snapshot("sk_model_routing", "openai") + let snapshot = crate::openai::snapshot("sk_model_routing", "openai"); + let settings = snapshot + .settings() + .clone() .with_model_mappings(mappings.clone()); + let snapshot = snapshot.with_settings(&settings).unwrap(); let response = request_model(snapshot, model, stream).await; assert_eq!( diff --git a/backend/crates/gateway-api/tests/openai/middleware/mod.rs b/backend/crates/gateway-api/tests/openai/middleware/mod.rs index 0ccef5e77..9c0766904 100644 --- a/backend/crates/gateway-api/tests/openai/middleware/mod.rs +++ b/backend/crates/gateway-api/tests/openai/middleware/mod.rs @@ -28,6 +28,7 @@ pub(super) struct RequestMiddleware { pub(super) response_actions: Mutex>, pub(super) response_headers: Vec, pub(super) short_circuit_json: Option, + pub(super) settings: Option, } #[derive(Debug, Clone)] @@ -92,8 +93,8 @@ impl MiddlewarePlan for RequestMiddleware { fn handle( &self, context: MiddlewareContext, - request: MiddlewareRequest, - next: Box, + mut request: MiddlewareRequest, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Request); assert_eq!(context.operation(), self.expected_operation); @@ -107,6 +108,18 @@ impl MiddlewarePlan for RequestMiddleware { let actions = std::mem::take(&mut *self.response_actions.lock().unwrap()); let response_headers = self.response_headers.clone(); let short_circuit_json = self.short_circuit_json.clone(); + if let Some(update) = self.settings { + let settings = request.settings().expect("effective request settings"); + let mut values = settings.execution_values().expect("execution scope"); + update(&mut values); + let updated = settings + .replace_execution(&values, "test-middleware") + .map_err(|_| MiddlewareError::InvalidState); + request = match updated { + Ok(settings) => request.with_settings(settings), + Err(error) => return Box::pin(async move { Err(error) }), + }; + } Box::pin(async move { if let Some(body) = short_circuit_json { return Ok(MiddlewareResponse::new( diff --git a/backend/crates/gateway-api/tests/openai/mod.rs b/backend/crates/gateway-api/tests/openai/mod.rs index 63f176599..ebcbd376c 100644 --- a/backend/crates/gateway-api/tests/openai/mod.rs +++ b/backend/crates/gateway-api/tests/openai/mod.rs @@ -10,13 +10,12 @@ mod search; mod usage; use std::collections::{BTreeMap, BTreeSet}; -use std::num::NonZeroU32; use std::sync::Arc; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use async_trait::async_trait; use futures::future::BoxFuture; -use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy}; +use gateway_core::account::ProviderAccountId; use gateway_core::engine::admission::{ ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery, ClientAdmissionRequest, ClientAdmissionRestoreResult, @@ -98,6 +97,13 @@ pub(super) fn api_router_with_config( admin: gateway_admin::AdminServices, config: gateway_api::ApiConfig, ) -> axum::Router { + api_bundle(admin, config).router() +} + +pub(super) fn api_bundle( + admin: gateway_admin::AdminServices, + config: gateway_api::ApiConfig, +) -> gateway_api::ApiBundle { let execution = Arc::new(DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")), Arc::new(UnusedExecutionStore), @@ -106,7 +112,15 @@ pub(super) fn api_router_with_config( Arc::new(UnusedContinuation), Arc::new(IgnoredClientApiKeyUsage), )); - api_router_with_config_and_execution(admin, config, execution) + gateway_api::initialize( + config, + execution, + admin, + Vec::new(), + Arc::new(EmptyWorkerHealth), + Arc::new(TestLifecycle::default()), + ) + .unwrap() } fn api_router_with_config_and_execution( @@ -184,8 +198,12 @@ pub(super) fn authenticated_client_for_provider_with_limit( provider_name: &str, bytes: usize, ) -> AuthenticatedClient { - let snapshot = snapshot(plaintext, provider_name) - .with_responses_max_decompressed_body_bytes(std::num::NonZeroUsize::new(bytes).unwrap()); + let snapshot = snapshot(plaintext, provider_name); + let settings = snapshot + .settings() + .clone() + .with_responses_max_decompressed_body_bytes(bytes as u64); + let snapshot = snapshot.with_settings(&settings).unwrap(); let source = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(UnusedExecutionStore), @@ -204,11 +222,18 @@ pub(super) fn authenticated_client_with_min_versions( desktop: Option<&str>, cli: Option<&str>, ) -> AuthenticatedClient { - let snapshot = - snapshot(plaintext, "openai").with_min_codex_client_versions(CodexClientMinVersions::new( - desktop.map(|version| CodexClientVersion::parse(version).expect("Desktop min version")), - cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")), - )); + let snapshot = snapshot(plaintext, "openai"); + let settings = + snapshot + .settings() + .clone() + .with_min_codex_client_versions(CodexClientMinVersions::new( + desktop.map(|version| { + CodexClientVersion::parse(version).expect("Desktop min version") + }), + cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")), + )); + let snapshot = snapshot.with_settings(&settings).unwrap(); let source = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(UnusedExecutionStore), @@ -248,11 +273,7 @@ fn snapshot_with_client_key( ); RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - NonZeroU32::new(2).expect("concurrency"), - Duration::from_millis(1), - ), + gateway_core::settings::SettingsValues::new(2, 1, "smart", Default::default(), None, None), vec![provider.clone()], ["model-a", "model-b"] .into_iter() diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index 60bd9383b..d06b9b49a 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -156,10 +156,10 @@ impl ExecutionService for ModelsExecution { } } - fn public_models(&self, _: &AuthenticatedClient) -> Vec { + fn public_models(&self, client: &AuthenticatedClient) -> Vec { ["model-a", "model-b"] .into_iter() - .map(|model| PublicModelId::new(model).expect("model")) + .map(|model| PublicModelId::new(client.snapshot().mapped_model(model)).expect("model")) .collect() } @@ -196,6 +196,40 @@ fn authorized_request(path: &str) -> Request { .expect("build models request") } +#[tokio::test] +async fn request_settings_reach_query_terminal_without_mutating_authenticated_baseline() { + let mut execution = ModelsExecution::new(); + Arc::get_mut(&mut execution).unwrap().middleware = + Some(Arc::new(crate::openai::middleware::RequestMiddleware { + settings: Some(|settings| { + let mut runtime = serde_json::to_value(&settings.runtime).unwrap(); + runtime["model_mappings"] = serde_json::json!({"model-a":"plugin-model"}); + runtime["concurrency_wait_timeout_seconds"] = serde_json::json!(30); + settings.runtime = serde_json::from_value(runtime).unwrap(); + settings.disable_fast = true; + }), + ..Default::default() + })); + let app = api_router(execution.clone()).await; + for _ in 0..2 { + let response = app + .clone() + .oneshot(authorized_request("/v1/models")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["data"][0]["id"], "plugin-model"); + assert_eq!(body["data"][1]["id"], "model-b"); + assert_eq!( + execution.client.snapshot().mapped_model("model-a"), + "model-a" + ); + assert!(!execution.client.policy().account_scope().disable_fast()); + } +} + #[tokio::test] async fn model_queries_enter_the_same_chain_after_auth_and_hold_generation_until_body_end() { use std::sync::atomic::Ordering; diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index c118df08b..8853e3375 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -2382,7 +2382,7 @@ async fn buffered_response_never_merges_items_from_a_different_wire_response() { } #[tokio::test] -async fn buffered_response_middleware_replaces_body_and_filters_unsafe_headers() { +async fn buffered_response_middleware_replaces_body_and_preserves_explicit_headers() { let admin = crate::admin::AdminTestFixture::new().await.services; let trace = Arc::new(Trace::default()); let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]); @@ -2395,7 +2395,7 @@ async fn buffered_response_middleware_replaces_body_and_filters_unsafe_headers() )]) .with_response_headers(vec![ MiddlewareHeader::new("x-policy-result", Bytes::from_static(b"replaced")), - MiddlewareHeader::new("authorization", Bytes::from_static(b"hidden")), + MiddlewareHeader::new("authorization", Bytes::from_static(b"plugin-value")), ]), ); @@ -2403,7 +2403,7 @@ async fn buffered_response_middleware_replaces_body_and_filters_unsafe_headers() response_with_middleware(&admin, "openai", session, false, Some(middleware)).await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()["x-policy-result"], "replaced"); - assert!(response.headers().get(AUTHORIZATION).is_none()); + assert_eq!(response.headers()[AUTHORIZATION], "plugin-value"); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("read policy JSON body"); diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/connection/middleware/mod.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/connection/middleware/mod.rs new file mode 100644 index 000000000..a9ac02e46 --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/connection/middleware/mod.rs @@ -0,0 +1,378 @@ +use super::*; +use bytes::Bytes; +use gateway_core::{ + engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareContext, MiddlewareError, MiddlewareNext, MiddlewarePlan, + MiddlewareRequest, MiddlewareResponse, + }, + middleware::{http as http_contract, websocket as core}, + runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, +}; +#[derive(Debug, Default)] +struct Plan { + block_terminal: bool, + control_received: Arc, + incoming: AtomicUsize, + outgoing: AtomicUsize, + http_cancellation: Mutex>, + session_cancellation: Mutex>, +} +struct Lease; +impl ExtensionSetLease for Lease { + fn is_ready(&self) -> bool { + true + } +} +impl MiddlewarePlan for Plan { + fn has_http(&self) -> bool { + true + } + fn has_websocket(&self) -> bool { + true + } + fn handle_http( + &self, + context: http_contract::Context, + mut request: http_contract::Request, + next: http_contract::Next, + ) -> BoxFuture<'static, Result> { + *self.http_cancellation.lock().unwrap() = Some(context.cancellation); + assert_eq!(request.uri(), "/custom/websocket"); + *request.uri_mut() = "/v1/responses".parse().unwrap(); + next.run(request) + } + fn handle_websocket( + &self, + context: core::Context, + message: core::Message, + next: core::Next, + ) -> BoxFuture<'static, Result, MiddlewareError>> { + assert!( + context.plan.is_some(), + "消息回调必须继承连接冻结的服务组合计划" + ); + assert!( + context + .headers + .iter() + .any(|header| header.name() == "authorization" + && header.value().as_ref() == b"Bearer sk_ws_crossing_limit") + ); + *self.session_cancellation.lock().unwrap() = Some(context.cancellation.clone()); + match context.direction { + core::Direction::Incoming => self.incoming.fetch_add(1, Ordering::SeqCst), + core::Direction::Outgoing => self.outgoing.fetch_add(1, Ordering::SeqCst), + }; + let control_received = self.control_received.clone(); + let block_terminal = self.block_terminal; + Box::pin(async move { + if message.payload == "signal" { + control_received.notify_one(); + return Ok(None); + } + if context.direction == core::Direction::Incoming && message.payload == "custom.control" + { + context + .sender + .send(core::Message { + kind: core::Kind::Binary, + payload: Bytes::from_static(&[0, 255, 1]), + }) + .await?; + return Ok(None); + } + let mut output = next.run(message).await?; + if context.direction == core::Direction::Outgoing + && let Some(message) = output.as_mut() + && message.kind == core::Kind::Text + { + let mut value: Value = serde_json::from_slice(&message.payload).unwrap(); + if block_terminal && value["type"] == "response.completed" { + message.kind = core::Kind::Binary; + message.payload = Bytes::from(vec![1; 64 * 1024 * 1024]); + return Ok(output); + } + value["plugin"] = Value::Bool(true); + message.payload = Bytes::from(serde_json::to_vec(&value).unwrap()); + } + Ok(output) + }) + } + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + // 统一计划延续到连接内的模型调用,没有模型绑定时直接委托。 + next.run(request) + } +} + +#[tokio::test] +async fn turns_refresh_host_settings_and_key_identity_while_preserving_handshake_overrides() { + use gateway_core::{ + engine::execution::DefaultExecutionService, engine::provider::ProviderRegistry, + runtime::RuntimeSnapshotHandle, + }; + #[derive(Debug, Default)] + struct SettingsPlan(Mutex>); + impl MiddlewarePlan for SettingsPlan { + fn has_http(&self) -> bool { + true + } + fn handle_http( + &self, + _: http_contract::Context, + mut request: http_contract::Request, + next: http_contract::Next, + ) -> BoxFuture<'static, Result> { + let settings = request + .extensions_mut() + .get_mut::() + .unwrap(); + let baseline = settings.runtime.as_ref().unwrap(); + let mut values = serde_json::to_value(baseline.values()).unwrap(); + values["request_interval_ms"] = json!(0); + settings.runtime = Some( + baseline + .replace(serde_json::from_value(values).unwrap(), "handshake") + .unwrap(), + ); + next.run(request) + } + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + _: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + self.0.lock().unwrap().push( + serde_json::to_value(request.settings().unwrap().execution_values().unwrap()) + .unwrap(), + ); + Box::pin(async { Err(MiddlewareError::Rejected) }) + } + } + let initial = crate::openai::snapshot("sk_ws_settings", "openai"); + let snapshots = RuntimeSnapshotHandle::new(initial.clone()); + let execution = Arc::new(DefaultExecutionService::new( + snapshots.clone(), + Arc::new(crate::openai::UnusedExecutionStore), + ProviderRegistry::default(), + Arc::new(crate::openai::UnusedAdmissions), + Arc::new(crate::openai::UnusedContinuation), + Arc::new(crate::openai::IgnoredClientApiKeyUsage), + )); + let admin = crate::admin::AdminTestFixture::new().await; + let plan = Arc::new(SettingsPlan::default()); + let frozen = FrozenMiddlewarePlan::new( + plan.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("ws-settings".into()).unwrap(), + Arc::new(Lease), + ), + ); + let router = gateway_api::initialize( + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: vec![], + request_timeout_seconds: None, + request_id_header: "x-request-id".into(), + }, + execution, + admin.services, + vec![], + Arc::new(crate::openai::EmptyWorkerHealth), + Arc::new(crate::openai::TestLifecycle::default()), + ) + .unwrap() + .with_middleware(move |_| Some(frozen.clone())) + .router(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let mut request = format!("ws://{address}/v1/responses") + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert(AUTHORIZATION, "Bearer sk_ws_settings".parse().unwrap()); + let (mut socket, _) = connect_async(request).await.unwrap(); + for round in 0..3 { + if round == 1 { + let mut settings = serde_json::to_value(initial.settings()).unwrap(); + settings["request_interval_ms"] = json!(50); + settings["responses_max_decompressed_body_bytes"] = json!(2048); + snapshots.publish( + initial + .clone() + .with_settings(&serde_json::from_value(settings).unwrap()) + .unwrap(), + ); + } else if round == 2 { + snapshots.publish(crate::openai::snapshot("sk_replaced", "openai")); + } + socket + .send(ClientMessage::Text( + json!({"type":"response.create", "model":"model-a", "input":"hello"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let message = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let ClientMessage::Text(text) = message else { + panic!("expected error event") + }; + let event: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(event["type"], "error"); + assert_eq!( + plan.0.lock().unwrap().len(), + (round + 1).min(2), + "revoked Key must not reach model middleware" + ); + } + { + let observed = plan.0.lock().unwrap(); + assert_eq!( + observed[0]["runtime"]["responses_max_decompressed_body_bytes"], + 64 * 1024 * 1024 + ); + assert_eq!( + observed[1]["runtime"]["responses_max_decompressed_body_bytes"], + 2048 + ); + assert_eq!(observed[0]["runtime"]["request_interval_ms"], 0); + assert_eq!(observed[1]["runtime"]["request_interval_ms"], 0); + } + socket.close(None).await.unwrap(); + server.abort(); +} + +#[tokio::test] +async fn rewrite_upgrade_and_control_active_response_keep_http_context_until_connection_ends() { + let plan = Arc::new(Plan::default()); + let frozen = FrozenMiddlewarePlan::new( + plan.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("ws-middleware-test".into()).unwrap(), + Arc::new(Lease), + ), + ); + let (trace, mut socket, server) = + start_active_response_with_middleware(Some(frozen), "/custom/websocket").await; + // 升级后的默认会话仍持有入口上下文;101 正文结束不再提前取消它。 + assert!( + !plan + .http_cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); + assert!( + !plan + .session_cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); + socket + .send(ClientMessage::Text("custom.control".into())) + .await + .unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(matches!(reply, ClientMessage::Binary(bytes) if bytes.as_ref() == [0,255,1])); + assert_eq!(trace.starts.load(Ordering::SeqCst), 1); + assert!(!trace.cancelled.load(Ordering::SeqCst)); + trace.release_terminal.notify_one(); + let reply = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let ClientMessage::Text(text) = reply else { + panic!("terminal text") + }; + let event: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(event["type"], "response.completed"); + assert_eq!(event["plugin"], true); + assert!(plan.incoming.load(Ordering::SeqCst) >= 2); + assert!(plan.outgoing.load(Ordering::SeqCst) >= 3); + socket.close(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if plan + .session_cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let http_cancellation = plan.http_cancellation.lock().unwrap().clone().unwrap(); + tokio::time::timeout(Duration::from_secs(2), http_cancellation.cancelled()) + .await + .unwrap(); + server.abort(); +} + +#[tokio::test] +async fn input_plugin_runs_while_a_real_socket_write_is_blocked() { + let plan = Arc::new(Plan { + block_terminal: true, + ..Plan::default() + }); + let frozen = FrozenMiddlewarePlan::new( + plan.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("ws-backpressure-test".into()).unwrap(), + Arc::new(Lease), + ), + ); + let (trace, mut socket, server) = + start_active_response_with_middleware(Some(frozen), "/custom/websocket").await; + trace.release_terminal.notify_one(); + // 客户端不读取 64 MiB 输出,真实 TCP 写入将受接收窗口背压。 + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!trace.finalized.load(Ordering::SeqCst)); + socket + .send(ClientMessage::Text("signal".into())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), plan.control_received.notified()) + .await + .expect("control middleware must run without waiting for the blocked output"); + assert!(!trace.finalized.load(Ordering::SeqCst)); + socket.close(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if trace.finalized.load(Ordering::SeqCst) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("blocked response must finalize on disconnect"); + server.abort(); +} diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/connection/mod.rs similarity index 93% rename from backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs rename to backend/crates/gateway-api/tests/openai/responses/websocket/connection/mod.rs index f54aac70d..794433aca 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/connection/mod.rs @@ -1,3 +1,5 @@ +mod middleware; + use std::{ fmt, pin::Pin, @@ -237,7 +239,14 @@ impl Stream for TestSocket { type Item = Result; fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { - self.incoming.poll_recv(context) + let event = self.incoming.poll_recv(context); + // 模拟 Axum transport 的自动 Pong;pump 不再重复实现协议层应答。 + if let Poll::Ready(Some(Ok(Message::Ping(payload)))) = &event { + self.written + .send(Message::Pong(payload.clone())) + .expect("automatic Pong"); + } + event } } @@ -719,13 +728,44 @@ async fn start_active_response() -> ( Arc, TestClientSocket, tokio::task::JoinHandle<()>, +) { + start_active_response_with_middleware(None, "/v1/responses").await +} + +async fn start_active_response_with_middleware( + plan: Option, + path: &str, +) -> ( + Arc, + TestClientSocket, + tokio::task::JoinHandle<()>, ) { let trace = Arc::new(CrossingLimitTrace::default()); let execution = Arc::new(CrossingLimitExecution { client: authenticated_client("sk_ws_crossing_limit"), trace: Arc::clone(&trace), }); - let app = api_router(execution).await; + let app = if let Some(plan) = plan { + let admin = crate::admin::AdminTestFixture::new().await; + gateway_api::initialize( + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: None, + request_id_header: "x-request-id".into(), + }, + execution, + admin.services, + Vec::new(), + Arc::new(crate::openai::EmptyWorkerHealth), + Arc::new(crate::openai::TestLifecycle::default()), + ) + .unwrap() + .with_middleware(move |_| Some(plan.clone())) + .router() + } else { + api_router(execution).await + }; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind WebSocket test server"); @@ -735,7 +775,7 @@ async fn start_active_response() -> ( .await .expect("serve WebSocket test router"); }); - let mut request = format!("ws://{address}/v1/responses") + let mut request = format!("ws://{address}{path}") .into_client_request() .expect("WebSocket request"); request.headers_mut().insert( @@ -1097,3 +1137,33 @@ async fn active_response_receives_heartbeats_during_two_hundred_seconds_of_silen socket.close(None).await.expect("close WebSocket"); server.abort(); } + +#[tokio::test(start_paused = true)] +async fn client_close_interrupts_a_blocked_write() { + let PumpHarness { + mut connection, + incoming, + dropped, + .. + } = test_connection(true); + let write = connection.send_text( + "blocked".to_owned(), + WriteContext::connection(FramePhase::Data), + ); + tokio::pin!(write); + assert!( + tokio::time::timeout(Duration::from_millis(1), &mut write) + .await + .is_err() + ); + incoming + .send(Ok(Message::Close(None))) + .expect("client close"); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), write) + .await + .expect("read side observes close while write is blocked"), + Err(ConnectionWriteError::Closed) + )); + assert!(dropped.load(Ordering::Acquire)); +} diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs index eaf798466..b7530e782 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/forward.rs @@ -1,15 +1,23 @@ use std::collections::VecDeque; -use std::sync::{Arc, Mutex, atomic::Ordering}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; use std::time::Duration; use axum::http::{StatusCode, header::AUTHORIZATION}; use bytes::Bytes; -use futures::{SinkExt, StreamExt}; +use futures::{SinkExt, StreamExt, future::BoxFuture}; +use gateway_core::engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareContext, MiddlewareError, MiddlewareNext, MiddlewarePlan, + MiddlewareRequest, MiddlewareResponse, +}; use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError}; use gateway_core::error::{ ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ProviderError, ProviderErrorKind, }; use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader}; +use gateway_core::runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}; use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState}; use serde_json::{Value, json}; use tokio::net::TcpStream; @@ -142,6 +150,63 @@ fn upstream_failure( ) } +#[derive(Debug)] +struct InspectFailureMiddleware(Arc); + +impl ExtensionSetLease for InspectFailureMiddleware { + fn is_ready(&self) -> bool { + true + } +} + +impl MiddlewarePlan for InspectFailureMiddleware { + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + let drops = Arc::clone(&self.0); + Box::pin(async move { + let response = next.run(request).await?; + assert_eq!( + drops.load(Ordering::Acquire), + 0, + "next must retain the failed execution until the onion chain returns" + ); + assert_eq!(response.status_code(), 503); + Ok(response) + }) + } +} + +#[tokio::test] +async fn initial_failure_retains_execution_until_request_middleware_returns() { + let drops = Arc::new(AtomicUsize::new(0)); + let middleware = Arc::new(InspectFailureMiddleware(Arc::clone(&drops))); + let trace = Arc::new(AtomicFailureTrace { + initial_errors: Mutex::new(VecDeque::from([EngineError::EmptyRoutingPlan])), + session_drops: Arc::clone(&drops), + middleware: Some(FrozenMiddlewarePlan::new( + middleware.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("failure-inspector".to_owned()).unwrap(), + middleware, + ), + )), + ..AtomicFailureTrace::default() + }); + let (mut socket, _server) = connect(Arc::clone(&trace), vec![]).await; + send_request(&mut socket).await; + let error = next_error(&mut socket).await; + assert_eq!(error["status"], 503); + assert_eq!(error["error"]["code"], "no_available_provider"); + assert_no_duplicate_failure(&mut socket).await; + assert_eq!(drops.load(Ordering::Acquire), 1); + assert!(!trace.committed.load(Ordering::Acquire)); + socket.close(None).await.unwrap(); +} + #[tokio::test] async fn initial_http_or_opening_failure_preserves_status_and_structured_error() { // 分类故意都用 Unavailable:不能把收到的 401/429/500 等按网关分类改成 502。 diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs index c6ccf76ba..d5df4201f 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs @@ -308,6 +308,8 @@ struct AtomicFailureTrace { downstream_websocket_connection_ids: Mutex>, initial_errors: Mutex>, first_batch: Mutex>, + session_drops: Arc, + middleware: Option, } struct AtomicFailureSession { @@ -316,6 +318,12 @@ struct AtomicFailureSession { fail_before_first_event: bool, } +impl Drop for AtomicFailureSession { + fn drop(&mut self) { + self.trace.session_drops.fetch_add(1, Ordering::AcqRel); + } +} + impl ExecutionSession for AtomicFailureSession { fn next_event(&mut self) -> BoxFuture<'_, Result, EngineError>> { Box::pin(async move { @@ -391,6 +399,13 @@ struct AtomicFailureExecution { } impl ExecutionService for AtomicFailureExecution { + fn middleware_plan( + &self, + _: &gateway_core::engine::execution::PreparedRootExecution, + ) -> Option { + self.trace.middleware.clone() + } + fn authenticate( &self, plaintext: &str, diff --git a/backend/crates/gateway-core/Cargo.toml b/backend/crates/gateway-core/Cargo.toml index 4647c075b..1b9edf96f 100644 --- a/backend/crates/gateway-core/Cargo.toml +++ b/backend/crates/gateway-core/Cargo.toml @@ -17,8 +17,11 @@ base64 = "0.23.0" bytes = "1.11.1" chrono-tz = { version = "0.10", features = ["serde"] } futures = "0.3.32" +http = "1.5.0" +http-body = "1.0.1" +http-body-util = "0.1.5" futures-timer = "3.0.3" -serde = { version = "1.0.228", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive", "rc"] } serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] } sha2 = "0.11.0" semver = "1.0.27" diff --git a/backend/crates/gateway-core/src/account/model.rs b/backend/crates/gateway-core/src/account/model.rs index 9fc422088..ad2d33b46 100644 --- a/backend/crates/gateway-core/src/account/model.rs +++ b/backend/crates/gateway-core/src/account/model.rs @@ -245,7 +245,8 @@ impl fmt::Debug for PlaintextCredential { } /// Provider-owned 的任意 JSON object;公共层只搬运、不读取内部 key。 -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] pub struct OpaqueProviderData(Map); impl OpaqueProviderData { diff --git a/backend/crates/gateway-core/src/account/scope.rs b/backend/crates/gateway-core/src/account/scope.rs index effdafbb5..cde953e87 100644 --- a/backend/crates/gateway-core/src/account/scope.rs +++ b/backend/crates/gateway-core/src/account/scope.rs @@ -272,6 +272,11 @@ impl FrozenAccountScope { self.request_profiles.get(provider) } + #[must_use] + pub fn request_profiles(&self) -> &BTreeMap { + &self.request_profiles + } + /// Key 绑定分组的冻结 Fast 限制,与账号成员资格无关。 #[must_use] pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self { diff --git a/backend/crates/gateway-core/src/account/selection.rs b/backend/crates/gateway-core/src/account/selection.rs index 8d7b17c54..666fbfabc 100644 --- a/backend/crates/gateway-core/src/account/selection.rs +++ b/backend/crates/gateway-core/src/account/selection.rs @@ -14,7 +14,8 @@ use super::{ }; /// `runtime_settings.rotation_strategy` 的稳定值。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum RotationStrategy { Smart, QuotaResetPriority, diff --git a/backend/crates/gateway-core/src/engine/authentication.rs b/backend/crates/gateway-core/src/engine/authentication.rs index 750610a7a..6c23f28d3 100644 --- a/backend/crates/gateway-core/src/engine/authentication.rs +++ b/backend/crates/gateway-core/src/engine/authentication.rs @@ -18,11 +18,27 @@ pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024; /// API 从数据面 Authorization 头构造的有界认证信封。 #[derive(Clone, PartialEq, Eq)] pub struct ClientAuthenticationRequest { + settings: Option, authorization: Arc, native_bearer: Option, } impl ClientAuthenticationRequest { + #[must_use] + pub fn with_settings(mut self, settings: crate::settings::RequestSettings) -> Self { + self.settings = Some(settings); + self + } + + #[must_use] + pub fn settings(&self) -> Option<&crate::settings::RequestSettings> { + self.settings.as_ref() + } + + pub(crate) fn take_settings(&mut self) -> Option { + self.settings.take() + } + /// 创建入口认证信封;HTTP 语法与缺失头仍由 API owner 负责。 pub fn new(authorization: impl Into) -> Result { let authorization = authorization.into(); @@ -39,6 +55,7 @@ impl ClientAuthenticationRequest { .filter(|value| !value.is_empty()) .and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok()); Ok(Self { + settings: None, authorization: authorization.into(), native_bearer, }) diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index 4f36000d8..f0886de19 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -65,10 +65,10 @@ use crate::routing::{ RoutingContext, RuntimeSnapshot, UpstreamModelId, }; use crate::runtime::{RuntimeSnapshotHandle, RuntimeSnapshotPublisher}; +use crate::settings::RequestSettings; const MODEL_REQUEST_DEADLINE: Duration = Duration::from_secs(10 * 60); const COORDINATION_TIMEOUT: Duration = Duration::from_millis(100); -const MAX_NESTED_DEPTH: usize = 4; const MAX_NESTED_EXECUTIONS: usize = 16; const MAX_CONCURRENT_NESTED_EXECUTIONS: usize = 4; @@ -108,12 +108,32 @@ pub struct ExecutionRequestMetadata { #[derive(Clone)] pub struct AuthenticatedClient { + settings: Option, snapshot: Arc, policy: ClientPolicy, authentication: Option, } impl AuthenticatedClient { + fn execution_timeout(&self) -> Duration { + self.settings + .as_ref() + .and_then(|settings| settings.execution_timeout(self.policy.key_id())) + .unwrap_or(MODEL_REQUEST_DEADLINE) + } + + #[must_use] + pub fn request_settings(&self) -> Option<&RequestSettings> { + self.settings.as_ref() + } + + /// 后续仍须重新认证;这里只更新下一次认证使用的冻结配置。 + #[must_use] + pub fn with_request_settings(mut self, settings: RequestSettings) -> Self { + self.settings = Some(settings); + self + } + #[must_use] pub const fn snapshot(&self) -> &Arc { &self.snapshot @@ -157,6 +177,7 @@ pub struct StartExecution { /// 中间件短路时本值直接释放;调用 `next` 后必须原样交回 /// [`ExecutionService::start_prepared`],不能重新认证为另一个 Key。 pub struct PreparedRootExecution { + extension_scope: ExtensionCallScope, response_control: super::response_control::ResponseControl, client: AuthenticatedClient, request_id: ModelRequestId, @@ -168,6 +189,51 @@ pub struct PreparedRootExecution { } impl PreparedRootExecution { + #[must_use] + pub fn request_settings(&self) -> RequestSettings { + self.client + .settings + .clone() + .unwrap_or_else(|| RequestSettings::new(self.client.snapshot.clone())) + .with_execution( + &self.client.policy, + duration_ms( + self.deadline_at + .duration_since(self.started_at) + .unwrap_or_default(), + ), + ) + } + + /// 入口延续发起实例集合;保留已经交给调用方的取消句柄。 + #[must_use] + pub fn with_extension_scope(mut self, scope: ExtensionCallScope) -> Self { + self.extension_scope = scope; + self + } + + #[must_use] + pub fn extension_scope(&self) -> ExtensionCallScope { + self.extension_scope.clone() + } + + /// 设置已经在改写时完成编译;此处只将同一份结果应用到冻结身份和生命周期。 + pub fn apply_settings(&mut self, settings: &RequestSettings) -> Result<(), GatewayError> { + let deadline = settings + .execution_deadline(self.client.policy.key_id(), self.started_at) + .map_err(|_| { + GatewayError::new( + GatewayErrorKind::InvalidRequest, + "execution settings are invalid", + ) + })?; + self.client.policy = settings.apply_policy(self.client.policy.clone()); + self.client.snapshot = settings.snapshot(); + self.client.settings = Some(settings.clone()); + self.deadline_at = deadline; + Ok(()) + } + #[must_use] pub fn response_control(&self) -> super::response_control::ResponseControl { self.response_control.clone() @@ -176,13 +242,14 @@ impl PreparedRootExecution { fn new(client: AuthenticatedClient) -> Result { let started_at = SystemTime::now(); let deadline_at = started_at - .checked_add(MODEL_REQUEST_DEADLINE) + .checked_add(client.execution_timeout()) .ok_or_else(|| { GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") })?; let execution_effects = Arc::new(ExecutionEffects::default()); let execution_effects_baseline = execution_effects.epoch(); Ok(Self { + extension_scope: ExtensionCallScope::default(), client, response_control: super::response_control::ResponseControl::default(), request_id: new_request_id()?, @@ -433,13 +500,18 @@ pub struct BoundModelExecutionContext { authority: Arc, } +impl BoundModelExecutionContext { + #[must_use] + pub fn request_settings(&self) -> Option<&RequestSettings> { + self.authority.client.request_settings() + } +} + struct BoundModelExecutionAuthority { client: AuthenticatedClient, account_scope: Arc, - deadline_at: SystemTime, cancellation: CancellationToken, extension_scope: ExtensionCallScope, - graph: Arc, initiating_plugin_instance_id: String, } @@ -452,7 +524,6 @@ impl fmt::Debug for BoundModelExecutionContext { "initiating_plugin_instance_id", &self.authority.initiating_plugin_instance_id, ) - .field("deadline_at", &self.authority.deadline_at) .finish_non_exhaustive() } } @@ -523,6 +594,11 @@ pub trait ExecutionSession: Send { } pub trait ExecutionService: Send + Sync { + /// 未就绪时管理与健康接口仍可运行,模型认证按既有路径报告不可用。 + fn request_settings(&self) -> Option { + None + } + fn authenticate( &self, plaintext: &str, @@ -771,7 +847,9 @@ impl DefaultExecutionService { .find(|policy| policy.authorize().is_ok()) .cloned() .ok_or(ClientAuthenticationError::InvalidKey)?; + let policy = RequestSettings::new(snapshot.clone()).apply_policy(policy); Ok(AuthenticatedClient { + settings: None, snapshot, policy, authentication: Some(authentication), @@ -780,12 +858,15 @@ impl DefaultExecutionService { async fn authenticate_request_without_usage( &self, - authentication: ClientAuthenticationRequest, + mut authentication: ClientAuthenticationRequest, ) -> Result { - let snapshot = self - .snapshots - .acquire() - .map_err(|_| ClientAuthenticationError::SnapshotUnavailable)?; + let snapshot = match authentication.settings() { + Some(settings) => settings.snapshot(), + None => self + .snapshots + .acquire() + .map_err(|_| ClientAuthenticationError::SnapshotUnavailable)?, + }; let frontend = self .frontend_authentication .as_ref() @@ -819,7 +900,12 @@ impl DefaultExecutionService { } else { native_policy(&snapshot, &authentication)? }; + let policy = match authentication.settings() { + Some(settings) => settings.apply_policy(policy), + None => RequestSettings::new(snapshot.clone()).apply_policy(policy), + }; Ok(AuthenticatedClient { + settings: authentication.take_settings(), snapshot, policy, authentication: Some(authentication), @@ -879,6 +965,10 @@ impl DefaultExecutionService { "bound execution identity cannot enter the public start path", ) })?; + let authentication = match client.settings.clone() { + Some(settings) => authentication.with_settings(settings), + None => authentication, + }; client = self .authenticate_request_without_usage(authentication) .await @@ -901,6 +991,7 @@ impl DefaultExecutionService { metadata: ExecutionRequestMetadata, ) -> Result { let PreparedRootExecution { + extension_scope, client, response_control, request_id, @@ -915,7 +1006,7 @@ impl DefaultExecutionService { account_scope: Arc::clone(client.policy.account_scope()), deadline_at, cancellation, - extension_scope: ExtensionCallScope::default(), + extension_scope, required_provider: None, required_account: None, nested: None, @@ -1496,7 +1587,7 @@ impl DefaultExecutionService { parent_account, } = request; let parent = self.active_request(&parent_request_id)?; - if parent.extension_scope.len() >= MAX_NESTED_DEPTH { + if parent.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, "nested model execution depth is exhausted", @@ -1560,8 +1651,11 @@ impl DefaultExecutionService { async fn client_for_key_id( &self, client_key_id: &ClientApiKeyId, + settings: Option, ) -> Result { - if let Some(refresh) = &self.snapshot_refresh { + if settings.is_none() + && let Some(refresh) = &self.snapshot_refresh + { refresh.refresh().await.map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, @@ -1569,12 +1663,16 @@ impl DefaultExecutionService { ) })?; } - let snapshot = self.snapshots.acquire().map_err(|_| { - GatewayError::new( - GatewayErrorKind::Internal, - "current runtime snapshot is unavailable", - ) - })?; + let snapshot = settings + .as_ref() + .map(|settings| settings.snapshot()) + .map_or_else(|| self.snapshots.acquire(), Ok) + .map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "current runtime snapshot is unavailable", + ) + })?; let policy = snapshot .client_policy(client_key_id) .cloned() @@ -1588,8 +1686,12 @@ impl DefaultExecutionService { GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled") })?; Ok(AuthenticatedClient { + policy: match &settings { + Some(settings) => settings.apply_policy(policy), + None => RequestSettings::new(snapshot.clone()).apply_policy(policy), + }, + settings, snapshot, - policy, authentication: None, }) } @@ -1598,19 +1700,10 @@ impl DefaultExecutionService { &self, binding: BoundModelExecutionBinding, ) -> Result { - if binding.timeout.is_zero() { - return Err(GatewayError::new( - GatewayErrorKind::InvalidRequest, - "bound model execution timeout is invalid", - )); - } - let client = self.client_for_key_id(&binding.client_key_id).await?; - let deadline_at = SystemTime::now() - .checked_add(binding.timeout.min(MODEL_REQUEST_DEADLINE)) - .ok_or_else(|| { - GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") - })?; - if binding.extension_scope.len() >= MAX_NESTED_DEPTH { + let client = self + .client_for_key_id(&binding.client_key_id, binding.settings) + .await?; + if binding.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH { return Err(GatewayError::new( GatewayErrorKind::PolicyDenied, "plugin model execution depth exceeded", @@ -1630,12 +1723,8 @@ impl DefaultExecutionService { authority: Arc::new(BoundModelExecutionAuthority { client, account_scope, - deadline_at, cancellation: binding.cancellation, extension_scope, - graph: Arc::new(NestedExecutionGraph::new(Arc::new( - ExecutionEffects::default(), - ))), initiating_plugin_instance_id: binding.initiating_plugin_instance_id, }), }) @@ -1660,25 +1749,25 @@ impl DefaultExecutionService { "bound plugin invocation was cancelled", )); } - if authority - .deadline_at - .duration_since(SystemTime::now()) - .unwrap_or_default() - .is_zero() - { - return Err(GatewayError::new( - GatewayErrorKind::Timeout, - "bound plugin invocation deadline elapsed", - )); - } + let now = SystemTime::now(); + let deadline_at = now + .checked_add(authority.client.execution_timeout()) + .ok_or_else(|| { + GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") + })?; let (account_scope, required_provider) = restrict_model_execution_scope( authority.account_scope.as_ref(), provider, account.as_ref(), None, )?; - let permit = authority.graph.acquire()?; - let execution_effects_baseline = authority.graph.effects.epoch(); + // 连接承载多个独立执行;递归预算和副作用只在本次执行的子调用图内共享。 + // 父连接的取消仍通过 authority 传播,不能因新建执行丢失扩展调用链。 + let graph = Arc::new(NestedExecutionGraph::new(Arc::new( + ExecutionEffects::default(), + ))); + let permit = graph.acquire()?; + let execution_effects_baseline = graph.effects.epoch(); metadata.endpoint = "host.model".to_owned(); metadata.transport = ClientTransport::InternalPlugin; metadata.client_ip = None; @@ -1695,7 +1784,7 @@ impl DefaultExecutionService { AuthorizedExecution { account_scope, response_control: None, - deadline_at: authority.deadline_at, + deadline_at, cancellation: authority.cancellation.child_token(), extension_scope: authority.extension_scope.clone(), required_provider, @@ -1704,11 +1793,11 @@ impl DefaultExecutionService { bound: Some(BoundExecutionFacts { initiating_plugin_instance_id: authority.initiating_plugin_instance_id.clone(), }), - graph: Some(Arc::clone(&authority.graph)), + graph: Some(graph), execution_effects_baseline, nested_permit: Some(permit), }, - SystemTime::now(), + now, None, ) .await @@ -2084,6 +2173,10 @@ impl ExecutionStore for TransientExecutionStore { } impl ExecutionService for DefaultExecutionService { + fn request_settings(&self) -> Option { + self.snapshots.acquire().ok().map(RequestSettings::new) + } + fn authenticate( &self, plaintext: &str, @@ -2224,7 +2317,7 @@ impl ExecutionService for DefaultExecutionService { ) -> BoxFuture<'_, Result> { let client_key_id = client_key_id.clone(); Box::pin(async move { - let client = self.client_for_key_id(&client_key_id).await?; + let client = self.client_for_key_id(&client_key_id, None).await?; self.client_api_key_usage .record_used(client.policy.key_id()); PreparedRootExecution::new(client) @@ -2293,12 +2386,8 @@ impl NestedModelExecutionPort for DefaultExecutionService { ) -> BoxFuture<'_, Result, GatewayError>> { Box::pin(async move { let authority = &context.authority; - let remaining = authority - .deadline_at - .duration_since(SystemTime::now()) - .unwrap_or_default(); let cancellation = authority.cancellation.cancelled().fuse(); - let timeout = Delay::new(remaining).fuse(); + let timeout = Delay::new(MODEL_REQUEST_DEADLINE).fuse(); let catalog = self .client_model_catalog(&authority.client, &protocol, &client_version) .fuse(); diff --git a/backend/crates/gateway-core/src/engine/extensions.rs b/backend/crates/gateway-core/src/engine/extensions.rs index 20a5d5e00..62b04b3ad 100644 --- a/backend/crates/gateway-core/src/engine/extensions.rs +++ b/backend/crates/gateway-core/src/engine/extensions.rs @@ -12,6 +12,8 @@ pub struct ExtensionCallScope { } impl ExtensionCallScope { + pub const MAXIMUM_DEPTH: usize = 4; + #[must_use] pub fn contains(&self, instance_id: &str) -> bool { self.instance_ids.contains(instance_id) diff --git a/backend/crates/gateway-core/src/engine/middleware.rs b/backend/crates/gateway-core/src/engine/middleware.rs index c1e089d0e..96ba5f7b3 100644 --- a/backend/crates/gateway-core/src/engine/middleware.rs +++ b/backend/crates/gateway-core/src/engine/middleware.rs @@ -28,10 +28,17 @@ use crate::operation::{ }; use crate::policy::ClientApiKeyId; use crate::runtime::extensions::{ExtensionSetId, ExtensionSetReference}; +use crate::settings::RequestSettings; /// 中间件在一次逻辑请求中的挂载位置。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum MiddlewareMount { + /// 所有 HTTP 接口的认证、路由和正文解析之前。 + Http, + /// 下游 WebSocket 消息在默认解析和写入之前组合。 + WebSocket, + /// 类型化公开服务在执行默认业务逻辑前组合。 + Service, /// 客户端请求外层;一次逻辑请求只执行一次。 Request, /// 受管 Provider attempt 内层;每次真实 retry 都重新建立。 @@ -94,6 +101,7 @@ impl fmt::Debug for MiddlewareHeader { /// 一次中间件调用可修改的请求事实。 #[derive(Clone, PartialEq, Eq)] pub struct MiddlewareRequest { + settings: Option, protocol: String, headers: Vec, body: Bytes, @@ -114,6 +122,7 @@ impl MiddlewareRequest { pub fn new(protocol: impl Into, headers: Vec, body: Bytes) -> Self { let protocol = protocol.into(); Self { + settings: None, original_protocol: protocol.clone(), original_body: body.clone(), capabilities: None, @@ -123,6 +132,17 @@ impl MiddlewareRequest { } } + #[must_use] + pub fn with_settings(mut self, settings: RequestSettings) -> Self { + self.settings = Some(settings); + self + } + + #[must_use] + pub fn settings(&self) -> Option<&RequestSettings> { + self.settings.as_ref() + } + #[must_use] pub fn protocol(&self) -> &str { &self.protocol @@ -265,12 +285,18 @@ impl MiddlewareFrame { /// 返回正文是否经过至少一个中间件的替换、展开或丢弃映射。 /// - /// 该事实只在宿主进程内传播,不进入插件 RPC wire,插件不能自行伪造。 + /// 插件可读取该事实;对快照的修改不改变宿主确认的来源。 #[must_use] pub const fn transformed(&self) -> bool { self.transformed } + /// 返回来源事件的只读视图;读取不转移原结算和会话状态的所有权。 + #[must_use] + pub fn event(&self) -> Option<&ProviderEvent> { + self.envelope.as_ref().map(MiddlewareFrameEnvelope::event) + } + /// 继承宿主确认的改写事实;`false` 不能清除内层已经记录的改写。 #[must_use] pub fn with_transformed(mut self, transformed: bool) -> Self { @@ -286,7 +312,7 @@ impl MiddlewareFrame { /// 拆出插件可见正文与只能由宿主原样搬运的事实信封。 /// /// Runtime 在一对多转换时必须把信封附到最后一个输出;这样 `Completed` 不会在 - /// 前置输出交付前提前终结。信封不能被读取、序列化、复制或伪造。 + /// 前置输出交付前提前终结。读取快照不会转移信封的计量所有权。 #[must_use] pub fn into_parts( self, @@ -347,13 +373,20 @@ impl fmt::Debug for MiddlewareFrame { } } -/// Runtime 可移动但不能检查或构造的 Core 事实信封。 +/// 可读取完整事件、只能随正文移动一次的 Core 事实信封。 /// /// 该类型故意不实现 `Clone`,防止同一 usage/cost 在一对多响应中被复制。 pub struct MiddlewareFrameEnvelope { event: ProviderEvent, } +impl MiddlewareFrameEnvelope { + #[must_use] + pub const fn event(&self) -> &ProviderEvent { + &self.event + } +} + impl fmt::Debug for MiddlewareFrameEnvelope { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -453,7 +486,7 @@ impl MiddlewareResponse { ) } - /// 搬运宿主 terminal 的不透明来源事实;Runtime 不得序列化或自行构造。 + /// 搬运宿主 terminal 的来源事实;读取快照不转移其所有权。 #[must_use] pub fn with_envelope(mut self, envelope: MiddlewareResponseEnvelope) -> Self { self.envelope = Some(envelope); @@ -485,6 +518,11 @@ pub struct MiddlewareResponseEnvelope { } impl MiddlewareResponseEnvelope { + #[must_use] + pub const fn metadata(&self) -> &ProviderCallMetadata { + &self.metadata + } + pub(crate) fn into_provider_metadata(self) -> ProviderCallMetadata { self.metadata } @@ -498,9 +536,10 @@ impl fmt::Debug for MiddlewareResponseEnvelope { } } -/// Core 已冻结、但不会序列化敏感身份的调用事实。 +/// Core 冻结的调用事实;完整业务身份可由插件读取。 #[derive(Clone)] pub struct MiddlewareContext { + plan: Option, request_id: ModelRequestId, mount: MiddlewareMount, attempt_index: Option, @@ -519,6 +558,11 @@ pub struct MiddlewareContext { } impl MiddlewareContext { + #[must_use] + pub fn plan(&self) -> Option { + self.plan.clone() + } + #[must_use] pub fn new(target: MiddlewareTarget, authority: MiddlewareAuthority) -> Self { let MiddlewareTarget { @@ -541,6 +585,7 @@ impl MiddlewareContext { execution_effects, } = authority; Self { + plan: None, request_id, mount, attempt_index, @@ -604,13 +649,13 @@ impl MiddlewareContext { self.account_id.as_ref() } - /// 只供宿主 Runtime 匹配冻结 binding;不得序列化给插件。 + /// 返回冻结的业务归属,也用于匹配 binding。 #[must_use] pub const fn client_key_id(&self) -> &ClientApiKeyId { &self.client_key_id } - /// 只供宿主 Runtime 匹配冻结 binding;不得序列化给插件。 + /// 返回冻结的业务归属,也用于匹配 binding。 #[must_use] pub fn account_group_ids(&self) -> &[AccountGroupId] { &self.account_group_ids @@ -651,7 +696,7 @@ pub struct MiddlewareTarget { pub account_id: Option, } -/// Runtime 只用于授权求交的冻结主体;字段不得进入插件 wire。 +/// 冻结的业务主体与生命周期;不用于裁剪插件权限。 pub struct MiddlewareAuthority { pub client_key_id: ClientApiKeyId, pub account_group_ids: Arc<[AccountGroupId]>, @@ -684,6 +729,11 @@ impl fmt::Debug for MiddlewareContext { pub enum MiddlewareError { Rejected, Fault, + /// 保留跨进程失败,供外层插件读取;客户端响应由协议 owner 决定。 + Remote { + source: Box, + rejected: bool, + }, InvalidState, Gateway(GatewayError), Engine(EngineError), @@ -695,6 +745,7 @@ impl fmt::Display for MiddlewareError { formatter.write_str(match self { Self::Rejected => "middleware rejected the request", Self::Fault => "middleware call failed", + Self::Remote { source, .. } => return fmt::Display::fmt(source, formatter), Self::InvalidState => "middleware state is invalid", Self::Gateway(_) => "gateway middleware terminal failed", Self::Engine(_) => "execution middleware terminal failed", @@ -703,7 +754,24 @@ impl fmt::Display for MiddlewareError { } } -impl std::error::Error for MiddlewareError {} +impl std::error::Error for MiddlewareError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Remote { source, .. } => Some(source.as_ref()), + Self::Gateway(error) => Some(error), + Self::Engine(error) => Some(error), + Self::Provider(error) => Some(error), + _ => None, + } + } +} + +impl MiddlewareError { + #[must_use] + pub const fn is_rejected(&self) -> bool { + matches!(self, Self::Rejected | Self::Remote { rejected: true, .. }) + } +} impl From for MiddlewareError { fn from(error: GatewayError) -> Self { @@ -724,20 +792,58 @@ impl From for MiddlewareError { } /// 绑定当前调用且只能消费一次的宿主续体。 -pub trait MiddlewareNext: Send { - fn run( - self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result>; -} +pub type MiddlewareNext = + crate::middleware::Next; /// 一个发布代次冻结的统一数据面中间件计划。 pub trait MiddlewarePlan: Send + Sync + fmt::Debug { + fn has_service(&self) -> bool { + false + } + fn handle_service( + &self, + _context: crate::middleware::service::Context, + input: crate::middleware::service::Value, + next: crate::middleware::service::Next, + ) -> BoxFuture< + 'static, + Result, + > { + next.run(input) + } + + fn has_websocket(&self) -> bool { + false + } + fn handle_websocket( + &self, + _context: crate::middleware::websocket::Context, + message: crate::middleware::websocket::Message, + next: crate::middleware::websocket::Next, + ) -> BoxFuture<'static, Result, MiddlewareError>> + { + next.run(message) + } + + /// HTTP 入口在认证、路由与正文解析之前组合,无匹配时保留直接路径。 + fn has_http(&self) -> bool { + false + } + + fn handle_http( + &self, + _context: crate::middleware::http::Context, + request: crate::middleware::http::Request, + next: crate::middleware::http::Next, + ) -> BoxFuture<'static, Result> { + next.run(request) + } + fn handle( &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result>; } @@ -749,6 +855,51 @@ pub struct FrozenMiddlewarePlan { } impl FrozenMiddlewarePlan { + #[must_use] + pub fn has_service(&self) -> bool { + self.plan.has_service() + } + pub fn handle_service( + &self, + context: crate::middleware::service::Context, + input: crate::middleware::service::Value, + next: crate::middleware::service::Next, + ) -> BoxFuture< + 'static, + Result, + > { + self.plan.handle_service(context, input, next) + } + + #[must_use] + pub fn has_websocket(&self) -> bool { + self.plan.has_websocket() + } + pub fn handle_websocket( + &self, + context: crate::middleware::websocket::Context, + message: crate::middleware::websocket::Message, + next: crate::middleware::websocket::Next, + ) -> BoxFuture<'static, Result, MiddlewareError>> + { + self.plan.handle_websocket(context, message, next) + } + + #[must_use] + pub fn has_http(&self) -> bool { + self.plan.has_http() + } + + pub fn handle_http( + &self, + mut context: crate::middleware::http::Context, + request: crate::middleware::http::Request, + next: crate::middleware::http::Next, + ) -> BoxFuture<'static, Result> { + context.plan = Some(self.clone()); + self.plan.handle_http(context, request, next) + } + #[must_use] pub fn new(plan: Arc, generation: ExtensionSetReference) -> Self { Self { @@ -759,10 +910,11 @@ impl FrozenMiddlewarePlan { pub fn handle( &self, - context: MiddlewareContext, + mut context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { + context.plan = Some(self.clone()); self.plan.handle(context, request, next) } } diff --git a/backend/crates/gateway-core/src/engine/nested.rs b/backend/crates/gateway-core/src/engine/nested.rs index dbb34daa0..9c3c5e774 100644 --- a/backend/crates/gateway-core/src/engine/nested.rs +++ b/backend/crates/gateway-core/src/engine/nested.rs @@ -7,7 +7,6 @@ use std::{ fmt, sync::atomic::{AtomicUsize, Ordering}, - time::Duration, }; use futures::future::BoxFuture; @@ -58,14 +57,15 @@ pub struct NestedModelExecutionRequest { pub parent_account: Option, } -/// 管理页或 CLI 为一次插件调用显式绑定的模型执行身份。 +/// 插件为当前调用或连接显式绑定的模型执行身份。 /// -/// Key ID 由已获模型域访问的调用选择,发起实例由宿主确定。Core 在绑定时冻结当前 +/// Key ID 由插件选择,发起实例由宿主确定。Core 在绑定时冻结当前 /// Key 策略;每个实际模型请求仍独立经过准入、预算、账本与计费。 pub struct BoundModelExecutionBinding { + pub settings: Option, pub client_key_id: ClientApiKeyId, pub initiating_plugin_instance_id: String, - pub timeout: Duration, + /// 身份随父调用或连接回收;每个模型请求使用独立的执行期限。 pub cancellation: CancellationToken, /// 宿主保留的扩展调用链,异步观察不能通过新建模型请求清空防递归事实。 pub extension_scope: super::extensions::ExtensionCallScope, @@ -80,7 +80,6 @@ impl fmt::Debug for BoundModelExecutionBinding { "initiating_plugin_instance_id", &self.initiating_plugin_instance_id, ) - .field("timeout", &self.timeout) .finish_non_exhaustive() } } @@ -135,7 +134,7 @@ pub trait NestedModelExecutionPort: Send + Sync { client_version: String, ) -> BoxFuture<'_, Result, GatewayError>>; - /// 为单次受保护管理调用或 CLI 命令冻结显式执行身份与共享调用图。 + /// 为插件调用冻结显式执行身份;取消父调用会取消其所有模型执行。 fn bind( &self, binding: BoundModelExecutionBinding, diff --git a/backend/crates/gateway-core/src/engine/policy.rs b/backend/crates/gateway-core/src/engine/policy.rs index 41dc6c5ed..30911043c 100644 --- a/backend/crates/gateway-core/src/engine/policy.rs +++ b/backend/crates/gateway-core/src/engine/policy.rs @@ -52,6 +52,11 @@ impl ModelRouteInput { &self.account_group_ids } + #[must_use] + pub fn extension_scope(&self) -> &ExtensionCallScope { + &self.extension_scope + } + #[must_use] pub fn suppresses_plugin(&self, instance_id: &str) -> bool { self.extension_scope.contains(instance_id) @@ -200,6 +205,11 @@ impl AccountScheduleInput { &self.account_group_ids } + #[must_use] + pub fn extension_scope(&self) -> &ExtensionCallScope { + &self.extension_scope + } + #[must_use] pub fn suppresses_plugin(&self, instance_id: &str) -> bool { self.extension_scope.contains(instance_id) diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index 227fe3d1d..66f120db1 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -468,29 +468,22 @@ const MAX_ATTEMPT_HEADER_NAME_BYTES: usize = 128; const MAX_ATTEMPT_HEADER_VALUE_BYTES: usize = 16 * 1024; const MAX_ATTEMPT_HEADER_TOTAL_BYTES: usize = 64 * 1024; -struct ProviderMiddlewareNext { +fn provider_middleware_next( operation: Operation, transport: ClientTransport, - terminal: Option, -} - -impl MiddlewareNext for ProviderMiddlewareNext { - fn run( - mut self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { + terminal: ProviderMiddlewareTerminal, +) -> MiddlewareNext { + crate::middleware::compose(Vec::new(), move |request| { Box::pin(async move { - let fallback_protocol = self.operation.protocol().to_owned(); + let fallback_protocol = operation.protocol().to_owned(); if request.has_capability_declaration() { return Err(MiddlewareError::InvalidState); } let (protocol, headers, body) = request.into_parts(); validate_attempt_headers(&headers)?; - let operation = self - .operation + let operation = operation .replace_middleware_wire(protocol, body) .map_err(|_| MiddlewareError::InvalidState)?; - let terminal = self.terminal.take().ok_or(MiddlewareError::InvalidState)?; let stream = terminal(operation, headers) .await .map_err(MiddlewareError::Provider)?; @@ -499,14 +492,14 @@ impl MiddlewareNext for ProviderMiddlewareNext { let body = ProviderMiddlewareBody { stream, pending: VecDeque::new(), - transport: self.transport, + transport, }; Ok( MiddlewareResponse::new(protocol, 200, Vec::new(), Box::new(body)) .with_provider_metadata(metadata), ) }) - } + }) } fn validate_attempt_headers(headers: &[MiddlewareHeader]) -> Result<(), MiddlewareError> { @@ -516,7 +509,6 @@ fn validate_attempt_headers(headers: &[MiddlewareHeader]) -> Result<(), Middlewa let mut total = 0_usize; for header in headers { let name = header.name(); - let normalized = name.to_ascii_lowercase(); if name.is_empty() || name.len() > MAX_ATTEMPT_HEADER_NAME_BYTES || !name @@ -527,7 +519,6 @@ fn validate_attempt_headers(headers: &[MiddlewareHeader]) -> Result<(), Middlewa .value() .iter() .any(|byte| *byte != b'\t' && (*byte < b' ' || *byte == 0x7f)) - || attempt_header_is_protected(&normalized) { return Err(MiddlewareError::InvalidState); } @@ -542,48 +533,6 @@ fn validate_attempt_headers(headers: &[MiddlewareHeader]) -> Result<(), Middlewa Ok(()) } -fn attempt_header_is_protected(name: &str) -> bool { - name.contains("auth") - || name.contains("credential") - || name.contains("secret") - || name.contains("token") - || name.contains("cookie") - || name.contains("session") - || name.contains("conversation") - || name.contains("thread") - || name.contains("account") - || name.contains("organization") - || name.contains("project") - || name.contains("tenant") - || name.contains("principal") - || name.contains("identity") - || name.contains("user-id") - || name.ends_with("-key") - || name.ends_with("_key") - || name.starts_with("sec-websocket-") - || matches!( - name, - "connection" - | "keep-alive" - | "proxy-connection" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "host" - | "content-length" - | "content-type" - | "content-encoding" - | "accept" - | "accept-encoding" - | "user-agent" - | "x-request-id" - | "x-gateway-request-id" - ) -} - struct ProviderMiddlewareBody { stream: ProviderStream, pending: VecDeque<(ProviderEvent, bool)>, @@ -713,11 +662,7 @@ pub async fn execute_attempt_middleware( .handle( context, request, - Box::new(ProviderMiddlewareNext { - operation, - transport, - terminal: Some(terminal), - }), + provider_middleware_next(operation, transport, terminal), ) .await .map_err(middleware_prepare_error)?; @@ -806,11 +751,14 @@ fn middleware_frame_to_provider_event( fn middleware_prepare_error(error: MiddlewareError) -> ProviderError { match error { MiddlewareError::Provider(error) => error, - MiddlewareError::Rejected => ProviderError::new( - ProviderErrorKind::RequestPolicyDenied, - UpstreamSendState::NotSent, - ), + MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { + ProviderError::new( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::NotSent, + ) + } MiddlewareError::Fault + | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState | MiddlewareError::Gateway(_) | MiddlewareError::Engine(_) => middleware_protocol_error(UpstreamSendState::NotSent), @@ -820,11 +768,14 @@ fn middleware_prepare_error(error: MiddlewareError) -> ProviderError { fn middleware_body_error(error: MiddlewareError) -> ProviderError { match error { MiddlewareError::Provider(error) => error, - MiddlewareError::Rejected => ProviderError::new( - ProviderErrorKind::RequestPolicyDenied, - UpstreamSendState::Ambiguous, - ), + MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { + ProviderError::new( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::Ambiguous, + ) + } MiddlewareError::Fault + | MiddlewareError::Remote { .. } | MiddlewareError::InvalidState | MiddlewareError::Gateway(_) | MiddlewareError::Engine(_) => middleware_protocol_error(UpstreamSendState::Ambiguous), diff --git a/backend/crates/gateway-core/src/event.rs b/backend/crates/gateway-core/src/event.rs index d82d42f5f..21cfddc00 100644 --- a/backend/crates/gateway-core/src/event.rs +++ b/backend/crates/gateway-core/src/event.rs @@ -842,13 +842,13 @@ impl ProviderEvent { /// 返回协议表达是否由中间件替换、展开或丢弃映射产生。 /// - /// 该事实只供宿主最终协议边界决定是否启用严格复核,不进入插件 wire。 + /// 宿主最终协议边界据此启用严格复核,插件可读取其快照。 #[must_use] pub const fn middleware_transformed(&self) -> bool { self.middleware_transformed } - /// 返回中间件第一次改写前的协议表达;仅宿主协议复核可见。 + /// 返回中间件第一次改写前的协议表达,供协议复核和插件读取。 #[must_use] pub fn middleware_origin_wire(&self) -> Option<&ProtocolWireEvent> { self.middleware_origin_wire.as_deref() diff --git a/backend/crates/gateway-core/src/identity.rs b/backend/crates/gateway-core/src/identity.rs index a86dc28cf..0d06606b7 100644 --- a/backend/crates/gateway-core/src/identity.rs +++ b/backend/crates/gateway-core/src/identity.rs @@ -4,9 +4,17 @@ use crate::validation::{IdentifierError, validate_text}; use std::fmt; /// 编译进二进制的 Provider adapter slug。 -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(transparent)] pub struct ProviderKind(String); +impl<'de> serde::Deserialize<'de> for ProviderKind { + fn deserialize>(deserializer: D) -> Result { + Self::new(::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + impl ProviderKind { /// 校验 Provider slug。 pub fn new(value: impl Into) -> Result { diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index 3191cbe95..3a9120fc1 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -13,11 +13,13 @@ pub mod health; pub mod identity; pub mod lifecycle; pub mod metering; +pub mod middleware; pub mod operation; pub mod policy; pub mod provider_ports; pub mod routing; pub mod runtime; +pub mod settings; pub mod task; pub mod upstream; pub mod validation; diff --git a/backend/crates/gateway-core/src/middleware/http/mod.rs b/backend/crates/gateway-core/src/middleware/http/mod.rs new file mode 100644 index 000000000..f4ce63e85 --- /dev/null +++ b/backend/crates/gateway-core/src/middleware/http/mod.rs @@ -0,0 +1,55 @@ +//! HTTP 中间件的值与流合同;不包含服务器、客户端或业务路由实现。 + +pub mod upgrade; + +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt as _, Empty, combinators::UnsyncBoxBody}; + +use crate::{engine::middleware::MiddlewareError, lifecycle::CancellationToken}; + +/// 正文保留数据帧、trailers 和背压;不要求完整读取或重新编码。 +pub type Body = UnsyncBoxBody>; +pub type Request = http::Request; +pub type Response = http::Response; +pub type Next = super::Next; + +/// 路由前调用没有 Client Key;身份在默认认证流程中产生。 +#[derive(Clone, Debug)] +pub struct Context { + pub request_id: String, + pub plugin_instance_id: Option, + pub call_id: String, + pub parent_call_id: Option, + pub extensions: crate::engine::extensions::ExtensionCallScope, + pub plan: Option, + pub cancellation: CancellationToken, +} + +/// 内部子请求复用 API 总路由;端口不解释业务路径,也不经网络回环。 +pub trait Dispatcher: Send + Sync { + fn request_settings(&self) -> Option { + None + } + + fn dispatch( + &self, + context: Context, + request: Request, + ) -> futures::future::BoxFuture<'static, Result>; +} + +/// 宿主解析后放入请求 extensions,插件显式覆盖后由终端消费。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Settings { + pub timeout: Option, + pub runtime: Option, +} + +#[must_use] +pub fn empty_body() -> Body { + Empty::::new() + .map_err(|never| match never {}) + .boxed_unsync() +} diff --git a/backend/crates/gateway-core/src/middleware/http/upgrade/mod.rs b/backend/crates/gateway-core/src/middleware/http/upgrade/mod.rs new file mode 100644 index 000000000..2c127b7af --- /dev/null +++ b/backend/crates/gateway-core/src/middleware/http/upgrade/mod.rs @@ -0,0 +1,44 @@ +//! 升级保留 HTTP 返回路径;会话任务由最外层传输在提交握手时接管。 + +use std::sync::{Arc, Mutex}; + +use futures::future::{BoxFuture, Shared}; + +use crate::{engine::middleware::MiddlewareError, middleware::websocket::Session}; + +pub type PendingSession = + Shared, Arc>>>; +pub type SessionTask = BoxFuture<'static, Result<(), MiddlewareError>>; + +/// 具体服务器负责握手与 socket;Core 不依赖 HTTP 服务器实现。 +pub trait WebSocketUpgrade: Send + Sync { + fn accept( + &self, + parts: http::request::Parts, + protocols: Vec, + ) -> BoxFuture<'static, Result<(super::Response, PendingSession), MiddlewareError>>; +} + +/// 响应被替换或丢弃时任务随之释放,不启动脱离请求的插件调用。 +#[derive(Clone)] +pub struct Upgraded { + pub status: http::StatusCode, + task: Arc>>, +} + +impl Upgraded { + #[must_use] + pub fn new(status: http::StatusCode, task: SessionTask) -> Self { + Self { + status, + task: Arc::new(Mutex::new(Some(task))), + } + } + + pub fn take_task(&self) -> Option { + self.task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } +} diff --git a/backend/crates/gateway-core/src/middleware/mod.rs b/backend/crates/gateway-core/src/middleware/mod.rs new file mode 100644 index 000000000..08da76e45 --- /dev/null +++ b/backend/crates/gateway-core/src/middleware/mod.rs @@ -0,0 +1,47 @@ +//! 与协议无关的洋葱组合器;输入、结果与错误保留调用方的领域类型。 + +pub mod http; +pub mod service; +pub mod websocket; + +use futures::future::BoxFuture; + +/// 单次调用中的一层;上下文放在类型化输入中或由闭包捕获。 +pub type Middleware = Box< + dyn FnOnce(Input, Next) -> BoxFuture<'static, Result> + + Send, +>; + +type Terminal = + Box BoxFuture<'static, Result> + Send>; + +/// 消费自身后进入下一层,所有权保证同一个续体不能运行两次。 +/// +/// 返回流或会话时,资源寿命由结果对象持有,组合器不后台驱动或缓冲结果。 +pub struct Next { + layers: std::vec::IntoIter>, + terminal: Terminal, +} + +impl Next { + /// 直接返回下一层 future,不启动与父调用脱离的任务。 + pub fn run(mut self, input: Input) -> BoxFuture<'static, Result> { + match self.layers.next() { + Some(layer) => layer(input, self), + None => (self.terminal)(input), + } + } +} + +/// 按给定顺序进入中间件,响应与错误沿等待栈逆序返回。 +/// +/// 空链直接调用终端;输入不需要 Clone,不经过 JSON 或字符串操作分派。 +pub fn compose( + layers: Vec>, + terminal: impl FnOnce(Input) -> BoxFuture<'static, Result> + Send + 'static, +) -> Next { + Next { + layers: layers.into_iter(), + terminal: Box::new(terminal), + } +} diff --git a/backend/crates/gateway-core/src/middleware/service/mod.rs b/backend/crates/gateway-core/src/middleware/service/mod.rs new file mode 100644 index 000000000..b5c5931c0 --- /dev/null +++ b/backend/crates/gateway-core/src/middleware/service/mod.rs @@ -0,0 +1,51 @@ +//! 公开服务的跨进程组合边界;原生调用仍使用所属领域的输入输出类型。 + +use crate::{ + engine::{extensions::ExtensionCallScope, middleware::FrozenMiddlewarePlan}, + lifecycle::CancellationToken, +}; + +/// 值只在进入插件链时编码;操作的领域校验与副作用由终端所属服务执行。 +pub type Value = serde_json::Value; +pub type Next = super::Next; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, thiserror::Error)] +#[error("{message}")] +pub struct Error { + pub kind: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl Error { + #[must_use] + pub fn invalid(message: impl Into) -> Self { + Self { + kind: "invalid".into(), + message: message.into(), + details: None, + } + } + + #[must_use] + pub fn unavailable(message: impl Into) -> Self { + Self { + kind: "unavailable".into(), + message: message.into(), + details: None, + } + } +} + +/// 子服务继承同一发布计划及取消信号;防递归集合不承担字段或权限过滤。 +#[derive(Clone, Debug)] +pub struct Context { + pub operation: &'static str, + pub request_id: String, + pub call_id: String, + pub parent_call_id: Option, + pub cancellation: CancellationToken, + pub extensions: ExtensionCallScope, + pub plan: FrozenMiddlewarePlan, +} diff --git a/backend/crates/gateway-core/src/middleware/websocket/mod.rs b/backend/crates/gateway-core/src/middleware/websocket/mod.rs new file mode 100644 index 000000000..98e1f5efa --- /dev/null +++ b/backend/crates/gateway-core/src/middleware/websocket/mod.rs @@ -0,0 +1,47 @@ +//! 双工消息边界,不解析消息正文或第三方控制协议。 +use crate::{ + engine::middleware::{MiddlewareError, MiddlewareHeader}, + lifecycle::CancellationToken, +}; +use bytes::Bytes; +use futures::future::BoxFuture; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + Incoming, + Outgoing, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Text, + Binary, + Ping, + Pong, + Close { code: Option }, +} +#[derive(Clone, Debug)] +pub struct Message { + pub kind: Kind, + pub payload: Bytes, +} + +/// 等待实际 transport 写入;与接收端分开持有,不在插件调用期间锁住 writer。 +pub trait Sender: Send + Sync { + fn send(&self, message: Message) -> BoxFuture<'_, Result<(), MiddlewareError>>; +} +/// 一个接收端和独立发送端;接收等待不能阻塞发送。 +pub trait Session: Sender { + fn receive(&self) -> BoxFuture<'_, Result, MiddlewareError>>; +} + +#[derive(Clone)] +pub struct Context { + pub plan: Option, + pub connection_id: String, + pub direction: Direction, + pub headers: Arc<[MiddlewareHeader]>, + pub cancellation: CancellationToken, + pub sender: Arc, +} +pub type Next = super::Next, MiddlewareError>; diff --git a/backend/crates/gateway-core/src/policy/mod.rs b/backend/crates/gateway-core/src/policy/mod.rs index 468bb8b53..bb5096014 100644 --- a/backend/crates/gateway-core/src/policy/mod.rs +++ b/backend/crates/gateway-core/src/policy/mod.rs @@ -8,10 +8,12 @@ pub use client_version::{ ClientVersionRejection, CodexClientKind, CodexClientMinVersions, CodexClientVersion, }; +use std::collections::BTreeMap; use std::fmt; use std::sync::Arc; -use crate::account::scope::FrozenAccountScope; +use crate::account::{OpaqueProviderData, scope::FrozenAccountScope}; +use crate::identity::ProviderKind; use crate::validation::{IdentifierError, PolicyError, validate_text}; /// `client_api_keys.id` 的核心值对象。 @@ -89,7 +91,8 @@ impl fmt::Debug for PlaintextClientApiKey { } /// 零表示对应维度不额外限制。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] pub struct RateLimits { pub max_concurrency: u64, pub requests_per_minute: u64, @@ -105,9 +108,18 @@ impl RateLimits { } } +/// Key 自身的设置默认值;请求派生策略保留本值,不能把宿主默认或插件覆盖写回。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientSettings { + pub request_profiles: BTreeMap, + pub disable_fast: bool, + pub limits: RateLimits, +} + /// 从 `client_api_keys` 冻结的公开准入事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientPolicy { + defaults: Arc, key_id: ClientApiKeyId, plaintext_key: PlaintextClientApiKey, account_scope: Arc, @@ -117,7 +129,33 @@ pub struct ClientPolicy { impl ClientPolicy { #[must_use] - pub const fn new( + pub fn defaults(&self) -> &Arc { + &self.defaults + } + + pub(crate) fn with_settings( + mut self, + profiles: &BTreeMap, + disable_fast: bool, + limits: RateLimits, + ) -> Self { + if self.account_scope.request_profiles() != profiles + || self.account_scope.disable_fast() != disable_fast + { + self.account_scope = Arc::new( + self.account_scope + .as_ref() + .clone() + .with_request_profiles(profiles.clone()) + .with_disable_fast(disable_fast), + ); + } + self.limits = limits; + self + } + + #[must_use] + pub fn new( key_id: ClientApiKeyId, plaintext_key: PlaintextClientApiKey, account_scope: Arc, @@ -125,6 +163,11 @@ impl ClientPolicy { limits: RateLimits, ) -> Self { Self { + defaults: Arc::new(ClientSettings { + request_profiles: account_scope.request_profiles().clone(), + disable_fast: account_scope.disable_fast(), + limits, + }), key_id, plaintext_key, account_scope, diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 1453ed494..e90cdfd2e 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -27,9 +27,17 @@ use crate::validation::{IdentifierError, RoutingError, validate_text}; const MAX_REQUEST_ATTEMPTS: u32 = 32; /// 客户端请求中的模型名称。 -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(transparent)] pub struct PublicModelId(String); +impl<'de> serde::Deserialize<'de> for PublicModelId { + fn deserialize>(deserializer: D) -> Result { + Self::new(::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + impl PublicModelId { pub fn new(value: impl Into) -> Result { let value = value.into(); @@ -66,9 +74,17 @@ impl fmt::Display for PublicModelId { } /// Provider 实际接收的模型名称。 -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(transparent)] pub struct UpstreamModelId(String); +impl<'de> serde::Deserialize<'de> for UpstreamModelId { + fn deserialize>(deserializer: D) -> Result { + Self::new(::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + impl UpstreamModelId { pub fn new(value: impl Into) -> Result { let value = value.into(); diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index d3b89c118..e0e2d0672 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -3,19 +3,16 @@ use std::collections::{BTreeMap, BTreeSet}; use std::num::NonZeroU32; use std::sync::Arc; -use std::time::Duration; use futures::future::BoxFuture; -use crate::account::{ - AccountConcurrency, AccountSelectionPolicy, ProviderAccountId, RotationStrategy, -}; +use crate::account::ProviderAccountId; use crate::concurrency::ConcurrencyQueuePolicy; use crate::operation::{Operation, OperationKind}; use crate::policy::{ - ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion, - PlaintextClientApiKey, RateLimits, + ClientApiKeyId, ClientPolicy, CodexClientMinVersions, PlaintextClientApiKey, RateLimits, }; +use crate::settings::{SettingsValues, compiled::CompiledSettings}; use crate::validation::RoutingError; use super::{ @@ -29,110 +26,6 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4; type ModelCatalogAccounts = BTreeMap>>; -/// Store 在一个一致性读取中提供的调度设置事实。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SnapshotSettingsFacts { - pricing: Arc, - request_profiles: BTreeMap, - request_location_enabled: bool, - request_location: crate::account::RequestLocation, - max_concurrent_per_account: u32, - max_waiting_per_key: u32, - max_waiting_per_account: u32, - concurrency_wait_timeout_seconds: u32, - responses_max_decompressed_body_bytes: u64, - request_interval_ms: u64, - smart_scheduling: crate::account::SmartSchedulingConfig, - rotation_strategy: String, - model_mappings: BTreeMap, - min_codex_desktop_version: Option, - min_codex_cli_version: Option, -} - -impl SnapshotSettingsFacts { - #[must_use] - pub const fn with_smart_scheduling( - mut self, - config: crate::account::SmartSchedulingConfig, - ) -> Self { - self.smart_scheduling = config; - self - } - - #[must_use] - pub fn with_pricing(mut self, pricing: crate::metering::PricingOverrides) -> Self { - self.pricing = Arc::new(pricing); - self - } - - #[must_use] - pub fn with_request_profiles( - mut self, - profiles: BTreeMap, - ) -> Self { - self.request_profiles = profiles; - self - } - - #[must_use] - pub const fn with_responses_max_decompressed_body_bytes(mut self, bytes: u64) -> Self { - self.responses_max_decompressed_body_bytes = bytes; - self - } - - #[must_use] - pub fn with_request_location( - mut self, - location: crate::account::RequestLocation, - enabled: bool, - ) -> Self { - self.request_location_enabled = enabled; - self.request_location = location; - self - } - - #[must_use] - pub const fn with_concurrency_queues( - mut self, - max_waiting_per_key: u32, - max_waiting_per_account: u32, - timeout_seconds: u32, - ) -> Self { - self.max_waiting_per_key = max_waiting_per_key; - self.max_waiting_per_account = max_waiting_per_account; - self.concurrency_wait_timeout_seconds = timeout_seconds; - self - } - - #[must_use] - pub fn new( - max_concurrent_per_account: u32, - request_interval_ms: u64, - rotation_strategy: impl Into, - model_mappings: BTreeMap, - min_codex_desktop_version: Option, - min_codex_cli_version: Option, - ) -> Self { - Self { - request_profiles: BTreeMap::new(), - pricing: Arc::default(), - request_location_enabled: false, - request_location: crate::account::RequestLocation::default(), - max_concurrent_per_account, - max_waiting_per_key: 0, - max_waiting_per_account: 0, - concurrency_wait_timeout_seconds: 30, - responses_max_decompressed_body_bytes: 64 * 1024 * 1024, - request_interval_ms, - smart_scheduling: crate::account::SmartSchedulingConfig::default(), - rotation_strategy: rotation_strategy.into(), - model_mappings, - min_codex_desktop_version, - min_codex_cli_version, - } - } -} - /// Store 读取到的一个启用 Client API Key 策略事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotClientPolicyFacts { @@ -246,7 +139,7 @@ impl SnapshotAccountGroupMemberFacts { pub struct SnapshotFacts { config_revision: ConfigRevision, observed_current_revision: ConfigRevision, - settings: SnapshotSettingsFacts, + settings: SettingsValues, client_policies: Vec, account_groups: Vec, provider_accounts: Vec, @@ -258,7 +151,7 @@ impl SnapshotFacts { pub fn new( config_revision: ConfigRevision, observed_current_revision: ConfigRevision, - settings: SnapshotSettingsFacts, + settings: SettingsValues, client_policies: Vec, account_groups: Vec, provider_accounts: Vec, @@ -561,53 +454,6 @@ async fn compile_runtime_snapshot( } let account_directory = Arc::new(RuntimeAccountDirectory::new(accounts)); - if facts.settings.max_waiting_per_key > 1_000 - || facts.settings.max_waiting_per_account > 1_000 - || !(1..=120).contains(&facts.settings.concurrency_wait_timeout_seconds) - { - return Err(RuntimeSnapshotCompileError::InvalidData); - } - let decompressed_body_limit = - isize::try_from(facts.settings.responses_max_decompressed_body_bytes) - .ok() - .and_then(|bytes| usize::try_from(bytes).ok()) - .and_then(std::num::NonZeroUsize::new) - .ok_or(RuntimeSnapshotCompileError::InvalidData)?; - let queue_timeout = - Duration::from_secs(u64::from(facts.settings.concurrency_wait_timeout_seconds)); - let client_queue_policy = ConcurrencyQueuePolicy { - max_waiting: facts.settings.max_waiting_per_key, - timeout: queue_timeout, - }; - let model_mappings = facts.settings.model_mappings; - let min_client_versions = CodexClientMinVersions::new( - facts - .settings - .min_codex_desktop_version - .as_deref() - .map(CodexClientVersion::parse) - .transpose() - .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, - facts - .settings - .min_codex_cli_version - .as_deref() - .map(CodexClientVersion::parse) - .transpose() - .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, - ); - let rotation_strategy = RotationStrategy::parse(facts.settings.rotation_strategy.as_str()) - .ok_or(RuntimeSnapshotCompileError::InvalidData)?; - let selection_policy = AccountSelectionPolicy::new( - rotation_strategy, - AccountConcurrency::new(facts.settings.max_concurrent_per_account), - Duration::from_millis(facts.settings.request_interval_ms), - ) - .with_smart_scheduling(facts.settings.smart_scheduling) - .with_queue(ConcurrencyQueuePolicy { - max_waiting: facts.settings.max_waiting_per_account, - timeout: queue_timeout, - }); let mut client_policies = Vec::with_capacity(facts.client_policies.len()); for policy in facts.client_policies { let mut disable_fast = false; @@ -645,36 +491,22 @@ async fn compile_runtime_snapshot( .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, ) }; - let mut request_profiles = facts.settings.request_profiles.clone(); - request_profiles.extend(policy.request_profiles); client_policies.push(ClientPolicy::new( policy.key_id, policy.plaintext_key, Arc::new( account_scope .with_disable_fast(disable_fast) - .with_request_profiles(request_profiles), + .with_request_profiles(policy.request_profiles), ), true, policy.limits, )); } - // 关闭自定义时保留持久化值,但不生成全局覆盖;请求继续使用客户端字段。 - let request_location = if facts.settings.request_location_enabled { - Some( - facts - .settings - .request_location - .normalized() - .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, - ) - } else { - None - }; RuntimeSnapshot::new( facts.config_revision, - selection_policy, + facts.settings, provider_kinds, provider_models, client_policies, @@ -682,42 +514,59 @@ async fn compile_runtime_snapshot( .map_err(|_| RuntimeSnapshotCompileError::InvalidData) .map(|snapshot| { snapshot - .with_pricing(facts.settings.pricing) - .with_request_location(request_location) - .with_responses_max_decompressed_body_bytes(decompressed_body_limit) - .with_client_queue_policy(client_queue_policy) - .with_model_mappings(model_mappings) .with_account_directory(account_directory) .with_exhaustive_provider_catalogs(exhaustive_provider_catalogs) .with_model_catalog_accounts(catalog_accounts) - .with_min_codex_client_versions(min_client_versions) }) } /// 数据面使用的不可变配置快照。 #[derive(Debug, Clone)] pub struct RuntimeSnapshot { + settings: Arc, extensions: Option, - pricing: Arc, - responses_max_decompressed_body_bytes: std::num::NonZeroUsize, - request_location: Option, revision: ConfigRevision, - client_queue_policy: ConcurrencyQueuePolicy, - account_selection_policy: AccountSelectionPolicy, providers: Arc>, provider_models: Arc>>, provider_model_presentations: Arc>>, - model_mappings: Arc>, provider_catalog_generations: Arc>, exhaustive_provider_catalogs: Arc>, model_catalog_accounts: Arc, account_directory: Arc, client_policies: Arc>, - min_codex_client_versions: CodexClientMinVersions, } impl RuntimeSnapshot { + /// 设置事实与编译结果共享快照寿命,读取时不从执行策略反向拼装。 + #[must_use] + pub fn settings(&self) -> &SettingsValues { + &self.settings.values + } + + pub(crate) fn resolve_settings( + self: &Arc, + values: &SettingsValues, + ) -> Result, RuntimeSnapshotCompileError> { + if self.settings() == values { + return Ok(self.clone()); + } + self.with_settings(values).map(Arc::new) + } + + /// 只替换设置及其编译结果,目录、发布代次和身份事实共享原快照。 + pub fn with_settings( + &self, + values: &SettingsValues, + ) -> Result { + let settings = CompiledSettings::new(values.clone()) + .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?; + Ok(Self { + settings: Arc::new(settings), + ..self.clone() + }) + } + #[must_use] pub fn with_extensions( mut self, @@ -749,9 +598,19 @@ impl RuntimeSnapshot { for alias in self.model_aliases() { let valid = ids.insert(&alias.id) && self.providers.contains(&alias.provider) - && !self.model_mappings.contains_key(alias.id.as_str()) - && !self.model_mappings.contains_key(alias.target.as_str()) && !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()) @@ -772,50 +631,21 @@ impl RuntimeSnapshot { } Ok(()) } + #[must_use] - pub fn with_pricing(mut self, pricing: Arc) -> Self { - self.pricing = pricing; - self + pub fn responses_max_decompressed_body_bytes(&self) -> usize { + self.settings.responses_max_decompressed_body_bytes.get() } #[must_use] - pub const fn responses_max_decompressed_body_bytes(&self) -> usize { - self.responses_max_decompressed_body_bytes.get() - } - - #[must_use] - pub const fn with_responses_max_decompressed_body_bytes( - mut self, - bytes: std::num::NonZeroUsize, - ) -> Self { - self.responses_max_decompressed_body_bytes = bytes; - self - } - - #[must_use] - pub fn with_request_location( - mut self, - location: Option, - ) -> Self { - self.request_location = location; - self - } - - #[must_use] - pub const fn client_queue_policy(&self) -> ConcurrencyQueuePolicy { - self.client_queue_policy - } - - #[must_use] - pub const fn with_client_queue_policy(mut self, policy: ConcurrencyQueuePolicy) -> Self { - self.client_queue_policy = policy; - self + pub fn client_queue_policy(&self) -> ConcurrencyQueuePolicy { + self.settings.client_queue_policy } /// 校验 Provider、实时模型目录和 Client API Key,并构建快照。 pub fn new( revision: ConfigRevision, - account_selection_policy: AccountSelectionPolicy, + values: SettingsValues, providers: Vec, provider_models: Vec, client_policies: Vec, @@ -879,46 +709,28 @@ impl RuntimeSnapshot { } client_policy_map.retain(|_, policy| policy.enabled()); + let settings = CompiledSettings::new(values).map_err(|_| RoutingError::InvalidSettings)?; Ok(Self { + settings: Arc::new(settings), extensions: None, - responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024) - .expect("positive default limit"), - pricing: Arc::default(), - request_location: None, revision, - account_selection_policy, - client_queue_policy: ConcurrencyQueuePolicy::default(), providers: Arc::new(provider_set), provider_models: Arc::new(model_map), provider_model_presentations: Arc::new(presentation_map), - model_mappings: Arc::new(BTreeMap::new()), provider_catalog_generations: Arc::new(BTreeMap::new()), exhaustive_provider_catalogs: Arc::new(exhaustive_provider_catalogs), model_catalog_accounts: Arc::default(), account_directory: Arc::new(RuntimeAccountDirectory::default()), client_policies: Arc::new(client_policy_map), - min_codex_client_versions: CodexClientMinVersions::default(), }) } - #[must_use] - pub fn with_model_mappings(mut self, mappings: BTreeMap) -> Self { - self.model_mappings = Arc::new(mappings); - self - } - #[must_use] pub fn with_account_directory(mut self, directory: Arc) -> Self { self.account_directory = directory; self } - #[must_use] - pub fn with_min_codex_client_versions(mut self, versions: CodexClientMinVersions) -> Self { - self.min_codex_client_versions = versions; - self - } - #[must_use] fn with_exhaustive_provider_catalogs(mut self, providers: BTreeSet) -> Self { self.exhaustive_provider_catalogs = Arc::new(providers); @@ -974,7 +786,9 @@ impl RuntimeSnapshot { ); } models.extend( - self.model_mappings + self.settings + .values + .model_mappings .keys() .filter_map(|model| PublicModelId::new(model.clone()).ok()), ); @@ -1002,7 +816,7 @@ impl RuntimeSnapshot { profiles.insert(public_model, presentation.clone()); } } - for alias in self.model_mappings.keys() { + for alias in self.settings.values.model_mappings.keys() { let target = self.mapped_model(alias); let Some(presentation) = presentations.iter().find_map(|(model, presentation)| { (model.as_str() == target).then_some(presentation) @@ -1143,7 +957,13 @@ impl RuntimeSnapshot { let mut current = original.to_owned(); let mut seen = BTreeSet::new(); for _ in 0..20 { - let Some(target) = self.model_mappings.get(¤t).map(String::as_str) else { + let Some(target) = self + .settings + .values + .model_mappings + .get(¤t) + .map(String::as_str) + else { return current; }; if !seen.insert(current.clone()) || seen.contains(target) { @@ -1186,8 +1006,8 @@ impl RuntimeSnapshot { } #[must_use] - pub const fn min_codex_client_versions(&self) -> &CodexClientMinVersions { - &self.min_codex_client_versions + pub fn min_codex_client_versions(&self) -> &CodexClientMinVersions { + &self.settings.min_codex_client_versions } pub fn plan( @@ -1255,7 +1075,11 @@ impl RuntimeSnapshot { } let requested_model = public_model.as_str(); let mapped_model = self.mapped_model(requested_model); - let upstream_model = if self.model_mappings.contains_key(requested_model) + let upstream_model = if self + .settings + .values + .model_mappings + .contains_key(requested_model) || self.model_alias(requested_model).is_some() { UpstreamModelId::new(mapped_model) @@ -1312,9 +1136,9 @@ impl RuntimeSnapshot { Ok(RoutingPlan { config_revision: self.revision, - pricing: Arc::clone(&self.pricing), - request_location: self.request_location.clone(), - account_selection_policy: self.account_selection_policy, + pricing: Arc::clone(&self.settings.values.pricing), + request_location: self.settings.request_location.clone(), + account_selection_policy: self.settings.account_selection_policy, operation: operation.kind(), max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS) .expect("constant request attempt limit is non-zero"), @@ -1379,9 +1203,9 @@ impl RuntimeSnapshot { }; Ok(RoutingPlan { config_revision: self.revision, - pricing: Arc::clone(&self.pricing), - request_location: self.request_location.clone(), - account_selection_policy: self.account_selection_policy, + pricing: Arc::clone(&self.settings.values.pricing), + request_location: self.settings.request_location.clone(), + account_selection_policy: self.settings.account_selection_policy, operation: operation.kind(), max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS) .expect("constant request attempt limit is non-zero"), diff --git a/backend/crates/gateway-core/src/settings/compiled.rs b/backend/crates/gateway-core/src/settings/compiled.rs new file mode 100644 index 000000000..7ff6a0c79 --- /dev/null +++ b/backend/crates/gateway-core/src/settings/compiled.rs @@ -0,0 +1,86 @@ +use std::time::Duration; + +use super::{InvalidSettings, SettingsValues}; +use crate::{ + account::{AccountConcurrency, AccountSelectionPolicy, RotationStrategy}, + concurrency::ConcurrencyQueuePolicy, + policy::{CodexClientMinVersions, CodexClientVersion}, +}; + +// 持久快照和请求覆盖共用同一编译结果,避免参数校验与派生规则分叉。 +#[derive(Debug, Clone)] +pub(crate) struct CompiledSettings { + pub(crate) values: SettingsValues, + pub(crate) account_selection_policy: AccountSelectionPolicy, + pub(crate) client_queue_policy: ConcurrencyQueuePolicy, + pub(crate) responses_max_decompressed_body_bytes: std::num::NonZeroUsize, + pub(crate) min_codex_client_versions: CodexClientMinVersions, + pub(crate) request_location: Option, +} + +impl CompiledSettings { + pub(crate) fn new(settings: SettingsValues) -> Result { + if settings.max_waiting_per_key > 1_000 + || settings.max_waiting_per_account > 1_000 + || !(1..=120).contains(&settings.concurrency_wait_timeout_seconds) + { + return Err(InvalidSettings); + } + let queue_timeout = + Duration::from_secs(u64::from(settings.concurrency_wait_timeout_seconds)); + let responses_max_decompressed_body_bytes = + isize::try_from(settings.responses_max_decompressed_body_bytes) + .ok() + .and_then(|bytes| usize::try_from(bytes).ok()) + .and_then(std::num::NonZeroUsize::new) + .ok_or(InvalidSettings)?; + let min_codex_client_versions = CodexClientMinVersions::new( + settings + .min_codex_desktop_version + .as_deref() + .map(CodexClientVersion::parse) + .transpose() + .map_err(|_| InvalidSettings)?, + settings + .min_codex_cli_version + .as_deref() + .map(CodexClientVersion::parse) + .transpose() + .map_err(|_| InvalidSettings)?, + ); + let strategy = + RotationStrategy::parse(&settings.rotation_strategy).ok_or(InvalidSettings)?; + let account_selection_policy = AccountSelectionPolicy::new( + strategy, + AccountConcurrency::new(settings.max_concurrent_per_account), + Duration::from_millis(settings.request_interval_ms), + ) + .with_smart_scheduling(settings.smart_scheduling) + .with_queue(ConcurrencyQueuePolicy { + max_waiting: settings.max_waiting_per_account, + timeout: queue_timeout, + }); + let client_queue_policy = ConcurrencyQueuePolicy { + max_waiting: settings.max_waiting_per_key, + timeout: queue_timeout, + }; + let request_location = settings + .request_location_enabled + .then(|| settings.request_location.clone().normalized()) + .transpose() + .map_err(|_| InvalidSettings)?; + for models in settings.pricing.values() { + for price in models.values() { + price.validate().map_err(|_| InvalidSettings)?; + } + } + Ok(Self { + account_selection_policy, + client_queue_policy, + responses_max_decompressed_body_bytes, + min_codex_client_versions, + request_location, + values: settings, + }) + } +} diff --git a/backend/crates/gateway-core/src/settings/mod.rs b/backend/crates/gateway-core/src/settings/mod.rs new file mode 100644 index 000000000..d75b00779 --- /dev/null +++ b/backend/crates/gateway-core/src/settings/mod.rs @@ -0,0 +1,381 @@ +//! 冻结宿主基线与请求显式改写;派生快照只属于当前调用,不发布全局配置。 + +use std::{ + collections::BTreeMap, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::policy::{ClientApiKeyId, ClientPolicy, ClientSettings, RateLimits}; +use crate::routing::RuntimeSnapshot; + +pub(crate) mod compiled; +mod values; +pub use values::SettingsValues; + +/// 一次模型调用的有效设置;改写只影响当前请求,不发布配置或修改持久化 revision。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionSettings { + pub runtime: SettingsValues, + pub disable_fast: bool, + pub client_limits: RateLimits, + /// 从本次请求开始计时,已经过去的时间不会因插件改写而重置。 + pub timeout_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettingOverride { + pub instance_id: String, + pub order: u64, + pub value: T, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct ExecutionOverrides { + #[serde(skip)] + defaults: Arc, + client_key_id: String, + input: Arc, + disable_fast: Option>, + client_limits: Option>, + timeout_ms: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct HttpTimeout { + input_ms: Option, + change: Option>>, +} + +/// 共享值均不可变;同级子调用的修改不会回写父调用。 +#[derive(Clone, Debug)] +pub struct RequestSettings { + baseline: Arc, + effective: Arc, + overrides: Arc>, + execution: Option>, + http_timeout: Option, + order: u64, +} + +impl PartialEq for RequestSettings { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.baseline, &other.baseline) + && self.overrides == other.overrides + && self.execution == other.execution + && self.http_timeout == other.http_timeout + } +} +impl Eq for RequestSettings {} + +#[derive(Debug, thiserror::Error)] +#[error("request settings are invalid")] +pub struct InvalidSettings; + +impl RequestSettings { + #[must_use] + pub fn new(snapshot: Arc) -> Self { + Self { + baseline: snapshot.clone(), + effective: snapshot, + overrides: Arc::default(), + execution: None, + http_timeout: None, + order: 0, + } + } + + #[must_use] + pub fn snapshot(&self) -> Arc { + self.effective.clone() + } + + #[must_use] + pub fn values(&self) -> &SettingsValues { + self.effective.settings() + } + + /// 来源与有效值分开查询,不允许插件伪造其他实例的写入来源。 + pub fn inspect(&self) -> Value { + serde_json::json!({ + "config_revision": self.baseline.revision().get(), + "host": self.baseline.settings(), + "overrides": self.overrides, + "execution": self.execution, + "http_timeout": self.http_timeout, + }) + } + + pub fn replace( + &self, + values: SettingsValues, + instance_id: &str, + ) -> Result { + self.replace_scoped(self.values(), values, instance_id) + } + + /// Key 画像等隐式作用域结果不记录成插件改写,避免随后切换 Key 时泄漏。 + fn replace_scoped( + &self, + previous: &SettingsValues, + values: SettingsValues, + instance_id: &str, + ) -> Result { + if previous == &values { + return Ok(self.clone()); + } + let current = serde_json::to_value(previous).map_err(|_| InvalidSettings)?; + let next = serde_json::to_value(&values).map_err(|_| InvalidSettings)?; + let order = self.order.checked_add(1).ok_or(InvalidSettings)?; + let mut overrides = self.overrides.as_ref().clone(); + let effective_values = self.values(); + // 每个已声明设置项整体替换,不猜测画像对象、数组或 null 的深合并含义。 + for (name, value) in next.as_object().ok_or(InvalidSettings)? { + if current.get(name) != Some(value) { + overrides.insert( + name.clone(), + SettingOverride { + instance_id: instance_id.to_owned(), + order, + value: value.clone(), + }, + ); + } + } + // 只把本层实际修改的字段写回宿主层,作用域默认值不参与继承。 + let values = if previous == effective_values { + values + } else { + apply_overrides(self.baseline.settings(), &overrides)? + }; + let effective = self + .effective + .resolve_settings(&values) + .map_err(|_| InvalidSettings)?; + Ok(Self { + baseline: self.baseline.clone(), + effective, + overrides: Arc::new(overrides), + execution: self.execution.clone(), + http_timeout: self.http_timeout.clone(), + order, + }) + } + + /// 持续会话的新请求读取当前宿主快照,只继承明确改写的字段。 + pub fn rebase(&self, snapshot: Arc) -> Result { + if Arc::ptr_eq(&self.baseline, &snapshot) { + return Ok(self.clone()); + } + let effective = if self.overrides.is_empty() { + snapshot.clone() + } else { + snapshot + .resolve_settings(&apply_overrides(snapshot.settings(), &self.overrides)?) + .map_err(|_| InvalidSettings)? + }; + Ok(Self { + baseline: snapshot, + effective, + overrides: self.overrides.clone(), + execution: self.execution.clone(), + http_timeout: self.http_timeout.clone(), + order: self.order, + }) + } + + /// 认证、模型入口和插件视图共用解析规则,派生策略不改变 Key 默认值。 + #[must_use] + pub fn apply_policy(&self, policy: ClientPolicy) -> ClientPolicy { + let values = self.resolve_execution(policy.key_id().as_str(), policy.defaults(), 0); + policy.with_settings( + values.runtime.request_profiles(), + values.disable_fast, + values.client_limits, + ) + } + + #[must_use] + pub fn with_execution(mut self, policy: &ClientPolicy, timeout_ms: u64) -> Self { + let input = self.resolve_execution(policy.key_id().as_str(), policy.defaults(), timeout_ms); + let previous = self + .execution + .as_ref() + .filter(|scope| scope.client_key_id == policy.key_id().as_str()); + self.execution = Some(Arc::new(ExecutionOverrides { + defaults: policy.defaults().clone(), + client_key_id: policy.key_id().as_str().to_owned(), + input: Arc::new(input), + disable_fast: previous.and_then(|scope| scope.disable_fast.clone()), + client_limits: previous.and_then(|scope| scope.client_limits.clone()), + timeout_ms: previous.and_then(|scope| scope.timeout_ms.clone()), + })); + self + } + + #[must_use] + pub fn execution_values(&self) -> Option { + let scope = self.execution.as_ref()?; + Some(self.resolve_execution( + &scope.client_key_id, + &scope.defaults, + scope.input.timeout_ms, + )) + } + + fn resolve_execution( + &self, + key: &str, + defaults: &ClientSettings, + timeout_ms: u64, + ) -> ExecutionSettings { + let overrides = self + .execution + .as_ref() + .filter(|scope| scope.client_key_id == key); + let mut runtime = self.values().clone(); + // Key 画像覆盖宿主默认,插件显式覆盖整个设置项;此优先级只在这里解释。 + if !self.overrides.contains_key("request_profiles") && !defaults.request_profiles.is_empty() + { + let mut profiles = runtime.request_profiles().clone(); + profiles.extend(defaults.request_profiles.clone()); + runtime = runtime.with_request_profiles(profiles); + } + ExecutionSettings { + runtime, + disable_fast: overrides + .and_then(|scope| scope.disable_fast.as_ref()) + .map_or(defaults.disable_fast, |change| change.value), + client_limits: overrides + .and_then(|scope| scope.client_limits.as_ref()) + .map_or(defaults.limits, |change| change.value), + timeout_ms: overrides + .and_then(|scope| scope.timeout_ms.as_ref()) + .map_or(timeout_ms, |change| change.value), + } + } + + pub(crate) fn execution_deadline( + &self, + key: &ClientApiKeyId, + started_at: SystemTime, + ) -> Result { + let scope = self + .execution + .as_ref() + .filter(|scope| scope.client_key_id == key.as_str()) + .ok_or(InvalidSettings)?; + let timeout_ms = scope + .timeout_ms + .as_ref() + .map_or(scope.input.timeout_ms, |change| change.value); + started_at + .checked_add(Duration::from_millis(timeout_ms)) + .ok_or(InvalidSettings) + } + + pub fn replace_execution( + &self, + values: &ExecutionSettings, + instance_id: &str, + ) -> Result { + let previous = self.execution_values().ok_or(InvalidSettings)?; + let mut updated = + self.replace_scoped(&previous.runtime, values.runtime.clone(), instance_id)?; + if previous.disable_fast == values.disable_fast + && previous.client_limits == values.client_limits + && previous.timeout_ms == values.timeout_ms + { + return Ok(updated); + } + let mut scope = self.execution.as_deref().cloned().ok_or(InvalidSettings)?; + let order = if updated.order == self.order { + self.order.checked_add(1).ok_or(InvalidSettings)? + } else { + updated.order + }; + if previous.disable_fast != values.disable_fast { + scope.disable_fast = Some(SettingOverride { + instance_id: instance_id.to_owned(), + order, + value: values.disable_fast, + }); + } + if previous.client_limits != values.client_limits { + scope.client_limits = Some(SettingOverride { + instance_id: instance_id.to_owned(), + order, + value: values.client_limits, + }); + } + if previous.timeout_ms != values.timeout_ms { + scope.timeout_ms = Some(SettingOverride { + instance_id: instance_id.to_owned(), + order, + value: values.timeout_ms, + }); + } + updated.execution = Some(Arc::new(scope)); + updated.order = order; + Ok(updated) + } + + #[must_use] + pub fn execution_timeout(&self, key: &ClientApiKeyId) -> Option { + self.execution + .as_ref() + .filter(|scope| scope.client_key_id == key.as_str()) + .and_then(|scope| scope.timeout_ms.as_ref()) + .map(|change| Duration::from_millis(change.value)) + } + + #[must_use] + pub fn with_http_timeout(mut self, timeout_ms: Option) -> Self { + self.http_timeout.get_or_insert(HttpTimeout { + input_ms: timeout_ms, + change: None, + }); + self + } + + pub fn replace_http_timeout( + mut self, + timeout_ms: Option, + instance_id: &str, + ) -> Result { + let timeout = self.http_timeout.get_or_insert(HttpTimeout { + input_ms: None, + change: None, + }); + if timeout + .change + .as_ref() + .map_or(timeout.input_ms, |change| change.value) + != timeout_ms + { + self.order = self.order.checked_add(1).ok_or(InvalidSettings)?; + timeout.change = Some(SettingOverride { + instance_id: instance_id.to_owned(), + order: self.order, + value: timeout_ms, + }); + } + Ok(self) + } +} + +fn apply_overrides( + values: &SettingsValues, + overrides: &BTreeMap, +) -> Result { + let mut values = serde_json::to_value(values).map_err(|_| InvalidSettings)?; + let object = values.as_object_mut().ok_or(InvalidSettings)?; + for (name, change) in overrides { + object.insert(name.clone(), change.value.clone()); + } + serde_json::from_value(values).map_err(|_| InvalidSettings) +} diff --git a/backend/crates/gateway-core/src/settings/values.rs b/backend/crates/gateway-core/src/settings/values.rs new file mode 100644 index 000000000..c72951caf --- /dev/null +++ b/backend/crates/gateway-core/src/settings/values.rs @@ -0,0 +1,132 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use crate::identity::ProviderKind; + +/// 请求可覆盖的运行设置事实;编译产物不能反向改写本值。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SettingsValues { + pub(crate) pricing: Arc, + pub(crate) request_profiles: Arc>, + pub(crate) request_location_enabled: bool, + pub(crate) request_location: crate::account::RequestLocation, + pub(crate) max_concurrent_per_account: u32, + pub(crate) max_waiting_per_key: u32, + pub(crate) max_waiting_per_account: u32, + pub(crate) concurrency_wait_timeout_seconds: u32, + pub(crate) responses_max_decompressed_body_bytes: u64, + pub(crate) request_interval_ms: u64, + pub(crate) smart_scheduling: crate::account::SmartSchedulingConfig, + pub(crate) rotation_strategy: String, + pub(crate) model_mappings: Arc>, + pub(crate) min_codex_desktop_version: Option, + pub(crate) min_codex_cli_version: Option, +} + +impl SettingsValues { + #[must_use] + pub fn request_profiles(&self) -> &BTreeMap { + &self.request_profiles + } + + #[must_use] + pub const fn with_smart_scheduling( + mut self, + config: crate::account::SmartSchedulingConfig, + ) -> Self { + self.smart_scheduling = config; + self + } + + #[must_use] + pub fn with_pricing( + mut self, + pricing: impl Into>, + ) -> Self { + self.pricing = pricing.into(); + self + } + + #[must_use] + pub fn with_request_profiles( + mut self, + profiles: BTreeMap, + ) -> Self { + self.request_profiles = Arc::new(profiles); + self + } + + #[must_use] + pub const fn with_responses_max_decompressed_body_bytes(mut self, bytes: u64) -> Self { + self.responses_max_decompressed_body_bytes = bytes; + self + } + + #[must_use] + pub fn with_request_location( + mut self, + location: crate::account::RequestLocation, + enabled: bool, + ) -> Self { + self.request_location_enabled = enabled; + self.request_location = location; + self + } + + #[must_use] + pub const fn with_concurrency_queues( + mut self, + max_waiting_per_key: u32, + max_waiting_per_account: u32, + timeout_seconds: u32, + ) -> Self { + self.max_waiting_per_key = max_waiting_per_key; + self.max_waiting_per_account = max_waiting_per_account; + self.concurrency_wait_timeout_seconds = timeout_seconds; + self + } + + #[must_use] + pub fn new( + max_concurrent_per_account: u32, + request_interval_ms: u64, + rotation_strategy: impl Into, + model_mappings: BTreeMap, + min_codex_desktop_version: Option, + min_codex_cli_version: Option, + ) -> Self { + Self { + request_profiles: Arc::default(), + pricing: Arc::default(), + request_location_enabled: false, + request_location: crate::account::RequestLocation::default(), + max_concurrent_per_account, + max_waiting_per_key: 0, + max_waiting_per_account: 0, + concurrency_wait_timeout_seconds: 30, + responses_max_decompressed_body_bytes: 64 * 1024 * 1024, + request_interval_ms, + smart_scheduling: crate::account::SmartSchedulingConfig::default(), + rotation_strategy: rotation_strategy.into(), + model_mappings: Arc::new(model_mappings), + min_codex_desktop_version, + min_codex_cli_version, + } + } + + #[must_use] + pub fn with_model_mappings(mut self, mappings: BTreeMap) -> Self { + self.model_mappings = Arc::new(mappings); + self + } + + #[must_use] + pub fn with_min_codex_client_versions( + mut self, + versions: crate::policy::CodexClientMinVersions, + ) -> Self { + self.min_codex_desktop_version = versions.desktop().map(ToString::to_string); + self.min_codex_cli_version = versions.cli().map(ToString::to_string); + self + } +} diff --git a/backend/crates/gateway-core/src/validation.rs b/backend/crates/gateway-core/src/validation.rs index 3307e9eab..58a976436 100644 --- a/backend/crates/gateway-core/src/validation.rs +++ b/backend/crates/gateway-core/src/validation.rs @@ -57,6 +57,9 @@ pub enum OperationError { /// 路由快照或 Route Plan 不满足不变量。 #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RoutingError { + /// 请求设置无法编译。 + #[error("runtime settings are invalid")] + InvalidSettings, /// Provider/model 路由标识无法构造。 #[error("routing identifier is invalid")] InvalidIdentifier, diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index 66db6fa9f..f3fc8ca53 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -678,7 +678,16 @@ fn plan_with_profiles( )); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("config revision"), - account_selection_policy, + gateway_core::settings::SettingsValues::new( + account_selection_policy.max_concurrent_per_account().get(), + u64::try_from(account_selection_policy.request_interval().as_millis()).unwrap(), + account_selection_policy.strategy().as_str(), + Default::default(), + None, + None, + ) + .with_smart_scheduling(account_selection_policy.smart_scheduling()) + .with_request_location(request_location, true), vec![provider.clone()], vec![ProviderModel::new( provider.clone(), @@ -688,7 +697,6 @@ fn plan_with_profiles( Vec::new(), ) .expect("snapshot") - .with_request_location(Some(request_location)) .with_account_directory(Arc::clone(&directory)); snapshot .plan( diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 8f69aea36..81dcbb701 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -1065,11 +1065,7 @@ fn request_policy_snapshot( ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); RuntimeSnapshot::new( ConfigRevision::new(1).unwrap(), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - std::num::NonZeroU32::new(2).unwrap(), - Duration::from_millis(1), - ), + gateway_core::settings::SettingsValues::new(2, 1, "smart", Default::default(), None, None), providers.to_vec(), providers .iter() @@ -1791,10 +1787,13 @@ fn model_routing_cannot_expand_the_frozen_key_model_scope() { ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).unwrap(), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - std::num::NonZeroU32::new(2).unwrap(), - Duration::from_millis(1), + gateway_core::settings::SettingsValues::new( + 2, + 1, + "smart", + Default::default(), + None, + None, ), vec![kind.clone()], ["gpt-start", "gpt-blocked"] @@ -2169,11 +2168,7 @@ fn fallback_observation_snapshot(reference: ExtensionSetReference) -> RuntimeSna ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); RuntimeSnapshot::new( ConfigRevision::new(1).unwrap(), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - std::num::NonZeroU32::new(2).unwrap(), - Duration::from_millis(1), - ), + gateway_core::settings::SettingsValues::new(2, 1, "smart", Default::default(), None, None), providers.to_vec(), providers .iter() @@ -2808,9 +2803,8 @@ use bytes::Bytes; use futures::{channel::oneshot, executor::block_on, future::BoxFuture}; use gateway_core::account::{ AccountCandidate, AccountEligibilityPolicy, AccountModelAccess, AccountModelAccessMode, - AccountRuntimeSignals, AccountSelectionContext, AccountSelectionPolicy, AccountWeight, - CredentialRevision, CredentialState, ProviderAccount, ProviderAccountId, QuotaState, - RotationStrategy, + AccountRuntimeSignals, AccountSelectionContext, AccountWeight, CredentialRevision, + CredentialState, ProviderAccount, ProviderAccountId, QuotaState, }; use gateway_core::engine::admission::{ ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery, @@ -3124,11 +3118,17 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models( } } for fail in [false, true] { - let snapshot = start_snapshot() + let snapshot = start_snapshot(); + let settings = snapshot + .settings() + .clone() .with_model_mappings(BTreeMap::from([ ("alias".to_owned(), "gpt-start".to_owned()), ("missing-alias".to_owned(), "unavailable-model".to_owned()), - ])) + ])); + let snapshot = snapshot + .with_settings(&settings) + .unwrap() .with_extensions(Some(ExtensionSetReference::new( gateway_core::runtime::extensions::ExtensionSetId::new("catalog-aliases".into()) .unwrap(), @@ -3807,11 +3807,7 @@ fn probe_snapshot() -> RuntimeSnapshot { ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); RuntimeSnapshot::new( ConfigRevision::new(1).expect("config revision"), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - std::num::NonZeroU32::new(1).expect("concurrency"), - Duration::from_millis(1), - ), + gateway_core::settings::SettingsValues::new(1, 1, "smart", Default::default(), None, None), vec![provider.clone()], vec![ProviderModel::new( provider, @@ -3827,11 +3823,7 @@ fn client_snapshot() -> RuntimeSnapshot { let provider = ProviderKind::new("openai").expect("provider kind"); RuntimeSnapshot::new( ConfigRevision::new(1).expect("config revision"), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - std::num::NonZeroU32::new(1).expect("concurrency"), - Duration::from_millis(1), - ), + gateway_core::settings::SettingsValues::new(1, 1, "smart", Default::default(), None, None), vec![provider.clone()], Vec::new(), vec![ClientPolicy::new( @@ -3860,11 +3852,7 @@ fn start_snapshot_with_policy( ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); RuntimeSnapshot::new( ConfigRevision::new(revision).expect("config revision"), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - std::num::NonZeroU32::new(1).expect("concurrency"), - Duration::from_millis(1), - ), + gateway_core::settings::SettingsValues::new(1, 1, "smart", Default::default(), None, None), vec![provider.clone()], vec![ProviderModel::new( provider.clone(), @@ -3977,11 +3965,9 @@ fn account_wait_inherits_the_budget_spent_during_client_admission() { requests_per_minute: 0, }, false, - ) - .with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy { - max_waiting: 1, - timeout: Duration::from_secs(1), - }); + ); + let settings = snapshot.settings().clone().with_concurrency_queues(1, 0, 1); + let snapshot = snapshot.with_settings(&settings).unwrap(); let service = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(TrackingExecutionStore::default()), @@ -4087,11 +4073,13 @@ fn queue_service( requests_per_minute: 0, }, false, - ) - .with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy { + ); + let settings = snapshot.settings().clone().with_concurrency_queues( max_waiting, - timeout, - }); + 0, + u32::try_from(timeout.as_secs()).unwrap(), + ); + let snapshot = snapshot.with_settings(&settings).unwrap(); let service = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(TrackingExecutionStore::default()), @@ -4176,7 +4164,7 @@ fn cancelled_waiter_releases_its_place_and_new_requests_do_not_overtake_fifo() { #[test] fn queue_timeout_and_rpm_rejection_leave_no_new_admission() { block_on(async { - let (service, admissions) = queue_service(1, 1, Duration::from_millis(20)); + let (service, admissions) = queue_service(1, 1, Duration::from_secs(1)); let running = service .start(request(&service, ClientTransport::HttpJson)) .await @@ -4241,6 +4229,184 @@ fn reused_websocket_client_gets_group_fast_policy_from_each_new_request_snapshot assert_eq!(*provider.policies.lock().unwrap(), vec![true, false]); } +#[test] +fn request_settings_recompute_routing_and_admission_without_changing_sibling_or_published_snapshot() +{ + block_on(async { + let limits = RateLimits { + max_concurrency: 3, + requests_per_minute: 9, + }; + let snapshot = start_snapshot_with_policy(1, true, limits, true); + let settings = snapshot + .settings() + .clone() + .with_concurrency_queues(0, 0, 30); + let snapshot = snapshot.with_settings(&settings).unwrap(); + let snapshots = RuntimeSnapshotHandle::new(snapshot); + let admissions = Arc::new(Admissions::default()); + let provider = Arc::new(ChargedProvider::default()); + let service = DefaultExecutionService::new( + snapshots.clone(), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::new([provider.clone() as Arc]).unwrap(), + admissions.clone(), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + let client = service.authenticate("sk_start_test").unwrap(); + let mut modified = service.prepare_verified_execution(client.clone()).unwrap(); + let sibling = service.prepare_verified_execution(client).unwrap(); + let baseline = sibling.request_settings().execution_values().unwrap(); + let mut settings = + serde_json::to_value(modified.request_settings().execution_values().unwrap()).unwrap(); + settings["runtime"]["model_mappings"] = json!({"request-alias":"gpt-start"}); + settings["runtime"]["request_interval_ms"] = json!(0); + settings["runtime"]["request_profiles"] = json!({"openai":{"identity":"request-local"}}); + settings["disable_fast"] = json!(false); + settings["client_limits"] = json!({"max_concurrency":0,"requests_per_minute":0}); + settings["timeout_ms"] = json!(120_000); + let settings = modified + .request_settings() + .replace_execution( + &serde_json::from_value(settings).unwrap(), + "settings-plugin", + ) + .unwrap(); + modified.apply_settings(&settings).unwrap(); + assert_eq!( + modified + .deadline_at() + .duration_since(modified.started_at()) + .unwrap(), + Duration::from_secs(120) + ); + assert_eq!( + modified.client().snapshot().mapped_model("request-alias"), + "gpt-start" + ); + assert_eq!( + sibling.request_settings().execution_values().unwrap(), + baseline + ); + assert_eq!( + serde_json::to_value( + modified + .client() + .policy() + .account_scope() + .request_profiles() + ) + .unwrap(), + json!({"openai":{"identity":"request-local"}}) + ); + assert!( + sibling + .client() + .policy() + .account_scope() + .request_profiles() + .is_empty() + ); + assert_eq!( + sibling.client().snapshot().mapped_model("request-alias"), + "request-alias" + ); + assert_eq!( + snapshots.acquire().unwrap().mapped_model("request-alias"), + "request-alias" + ); + assert_eq!( + modified.client().snapshot().revision(), + sibling.client().snapshot().revision() + ); + for (prepared, model) in [(modified, "request-alias"), (sibling, "gpt-start")] { + let input = request(&service, ClientTransport::HttpJson); + let mut started = service + .start_prepared( + prepared, + gateway_core::engine::execution::PreparedExecutionRequest { + public_model: PublicModelId::new(model).unwrap(), + operation: input.operation, + metadata: input.metadata, + }, + ) + .await + .unwrap(); + started.session.collect_uncommitted().await.unwrap(); + started.session.detach_finalize().await; + } + assert_eq!(*provider.policies.lock().unwrap(), vec![false, true]); + assert_eq!( + *admissions.limits.lock().unwrap(), + vec![RateLimits::unlimited(), limits] + ); + }); +} + +#[test] +fn invalid_request_settings_leave_the_prepared_execution_unchanged() { + let service = service(Arc::new(Admissions::default()), Arc::new(Budget::default())); + let client = service.authenticate("sk_start_test").unwrap(); + let prepared = service.prepare_verified_execution(client).unwrap(); + let baseline = prepared.request_settings().execution_values().unwrap(); + let mut invalid = serde_json::to_value(&baseline).unwrap(); + invalid["runtime"]["responses_max_decompressed_body_bytes"] = json!(0); + invalid["disable_fast"] = json!(true); + assert!( + prepared + .request_settings() + .replace_execution(&serde_json::from_value(invalid).unwrap(), "settings-plugin") + .is_err() + ); + assert_eq!( + prepared.request_settings().execution_values().unwrap(), + baseline + ); +} + +#[test] +fn execution_settings_must_match_the_prepared_key() { + use gateway_core::settings::RequestSettings; + + let service = service(Arc::new(Admissions::default()), Arc::new(Budget::default())); + let client = service.authenticate("sk_start_test").unwrap(); + let mut prepared = service.prepare_verified_execution(client).unwrap(); + let baseline = prepared.request_settings().execution_values().unwrap(); + let snapshot = prepared.client().snapshot().clone(); + let settings = RequestSettings::new(snapshot.clone()) + .replace( + baseline + .runtime + .clone() + .with_responses_max_decompressed_body_bytes(1024), + "settings-plugin", + ) + .unwrap(); + let other_key = ClientApiKeyId::new("other-key").unwrap(); + for settings in [ + settings.clone(), + settings.with_execution( + &ClientPolicy::new( + other_key, + prepared.client().policy().plaintext_key().clone(), + prepared.client().policy().account_scope().clone(), + true, + RateLimits::unlimited(), + ), + baseline.timeout_ms, + ), + ] { + assert!(prepared.apply_settings(&settings).is_err()); + assert!(Arc::ptr_eq(prepared.client().snapshot(), &snapshot)); + assert_eq!( + prepared.request_settings().execution_values().unwrap(), + baseline + ); + assert!(prepared.client().request_settings().is_none()); + } +} + #[test] fn repeated_connection_failures_never_block_later_requests_for_the_provider() { let store = Arc::new(TrackingExecutionStore::default()); @@ -4291,3 +4457,314 @@ fn repeated_connection_failures_never_block_later_requests_for_the_provider() { assert_eq!(store.requests.lock().unwrap().len(), 5); assert!(store.entry_rejections.lock().unwrap().is_empty()); } + +#[test] +fn entry_settings_freeze_authentication_and_rebase_only_explicit_overrides() { + block_on(async { + use gateway_core::settings::RequestSettings; + let snapshots = RuntimeSnapshotHandle::new(start_snapshot()); + let service = DefaultExecutionService::new( + snapshots.clone(), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::default(), + Arc::new(UnusedAdmissions), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + let baseline = service.request_settings().unwrap(); + let mut values = serde_json::to_value(baseline.values()).unwrap(); + values["responses_max_decompressed_body_bytes"] = json!(1024); + values["request_interval_ms"] = json!(0); + let first = baseline + .replace(serde_json::from_value(values).unwrap(), "first") + .unwrap(); + let mut values = serde_json::to_value(first.values()).unwrap(); + values["responses_max_decompressed_body_bytes"] = json!(2048); + values["min_codex_cli_version"] = json!("0.40.0"); + let second = first + .replace(serde_json::from_value(values).unwrap(), "second") + .unwrap(); + let mut values = serde_json::to_value(second.values()).unwrap(); + values["min_codex_cli_version"] = Value::Null; + let second = second + .replace(serde_json::from_value(values).unwrap(), "second") + .unwrap(); + let source = second.inspect(); + assert_eq!( + source["overrides"]["request_interval_ms"]["instance_id"], + "first" + ); + assert_eq!( + source["overrides"]["responses_max_decompressed_body_bytes"]["instance_id"], + "second" + ); + assert!(source["overrides"]["min_codex_cli_version"]["value"].is_null()); + assert_eq!( + first.snapshot().responses_max_decompressed_body_bytes(), + 1024 + ); + assert_eq!( + baseline.snapshot().responses_max_decompressed_body_bytes(), + 64 * 1024 * 1024 + ); + let request = ClientAuthenticationRequest::bearer("sk_start_test") + .unwrap() + .with_settings(second.clone()); + let client = service.authenticate_request(request).await.unwrap(); + snapshots.publish(start_snapshot_with_policy( + 2, + true, + RateLimits { + max_concurrency: 7, + requests_per_minute: 17, + }, + true, + )); + let prepared = service.prepare_execution(client.clone()).await.unwrap(); + assert_eq!(prepared.client().snapshot().revision().get(), 1); + assert_eq!( + prepared + .request_settings() + .execution_values() + .unwrap() + .runtime, + second.values().clone() + ); + let fresh = second + .rebase(service.request_settings().unwrap().snapshot()) + .unwrap(); + let prepared = service + .prepare_execution(client.with_request_settings(fresh)) + .await + .unwrap(); + assert_eq!(prepared.client().snapshot().revision().get(), 2); + assert_eq!( + prepared + .request_settings() + .execution_values() + .unwrap() + .client_limits + .max_concurrency, + 7 + ); + assert!( + prepared + .request_settings() + .execution_values() + .unwrap() + .disable_fast + ); + assert_eq!( + prepared + .client() + .snapshot() + .responses_max_decompressed_body_bytes(), + 2048 + ); + let original = RequestSettings::new(snapshots.acquire().unwrap()); + assert_eq!( + original.snapshot().responses_max_decompressed_body_bytes(), + 64 * 1024 * 1024 + ); + }); +} + +#[test] +fn unchanged_and_precompiled_request_settings_reuse_the_frozen_snapshot() { + use gateway_core::settings::RequestSettings; + + let snapshot = Arc::new(start_snapshot()); + let baseline = RequestSettings::new(snapshot.clone()); + let unchanged = baseline + .replace(baseline.values().clone(), "pass-through") + .unwrap(); + assert!(Arc::ptr_eq(&snapshot, &unchanged.snapshot())); + assert!( + unchanged.inspect()["overrides"] + .as_object() + .unwrap() + .is_empty() + ); + + let fresh = Arc::new(start_snapshot_with_policy( + 2, + true, + RateLimits::unlimited(), + true, + )); + let rebased = unchanged.rebase(fresh.clone()).unwrap(); + assert!(Arc::ptr_eq(&fresh, &rebased.snapshot())); + let values = rebased + .values() + .clone() + .with_responses_max_decompressed_body_bytes(1024); + let changed = rebased.replace(values, "settings-plugin").unwrap(); + assert!(Arc::ptr_eq( + &changed.snapshot(), + &changed.rebase(fresh).unwrap().snapshot() + )); + + let service = DefaultExecutionService::new( + RuntimeSnapshotHandle::new(start_snapshot()), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::default(), + Arc::new(UnusedAdmissions), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + let client = service.authenticate("sk_start_test").unwrap(); + let mut prepared = service.prepare_verified_execution(client).unwrap(); + let mut settings = prepared.request_settings().execution_values().unwrap(); + let original = prepared.request_settings(); + let policy_scope = prepared.client().policy().account_scope().clone(); + let deadline = prepared.deadline_at(); + prepared.apply_settings(&original).unwrap(); + assert!(Arc::ptr_eq( + prepared.client().snapshot(), + &original.snapshot() + )); + assert!(Arc::ptr_eq( + prepared.client().policy().account_scope(), + &policy_scope + )); + assert_eq!(prepared.deadline_at(), deadline); + settings.runtime = settings + .runtime + .with_responses_max_decompressed_body_bytes(1024); + let changed = original + .replace_execution(&settings, "settings-plugin") + .unwrap(); + prepared.apply_settings(&changed).unwrap(); + assert!(Arc::ptr_eq( + prepared.client().snapshot(), + &changed.snapshot() + )); + assert_eq!( + prepared.request_settings().execution_values().unwrap(), + settings + ); +} + +#[test] +fn child_settings_recompute_key_scope_without_inheriting_parent_defaults() { + block_on(async { + use gateway_core::{account::OpaqueProviderData, settings::RequestSettings}; + let provider = ProviderKind::new("openai").unwrap(); + let profiles = |name: &str| { + BTreeMap::from([( + provider.clone(), + OpaqueProviderData::new(json!({"identity":name}).as_object().unwrap().clone()), + )]) + }; + let original = start_snapshot(); + let parent_key = ClientApiKeyId::new("parent").unwrap(); + let child_key = ClientApiKeyId::new("child").unwrap(); + let policies = [ + (&parent_key, "sk_parent", "parent-default", 3), + (&child_key, "sk_child", "child-default", 9), + ] + .into_iter() + .map(|(key, token, name, max_concurrency)| { + ClientPolicy::new( + key.clone(), + PlaintextClientApiKey::new(token).unwrap(), + Arc::new( + account_scope(&provider, "acct_start") + .as_ref() + .clone() + .with_disable_fast(true) + .with_request_profiles(profiles(name)), + ), + true, + RateLimits { + max_concurrency, + requests_per_minute: 10, + }, + ) + }) + .collect(); + let snapshot = RuntimeSnapshot::new( + ConfigRevision::new(1).unwrap(), + gateway_core::settings::SettingsValues::new( + 1, + 0, + "smart", + Default::default(), + None, + None, + ), + vec![provider], + vec![], + policies, + ) + .unwrap() + .with_settings(original.settings()) + .unwrap(); + let snapshots = RuntimeSnapshotHandle::new(snapshot); + let service = DefaultExecutionService::new( + snapshots.clone(), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::default(), + Arc::new(UnusedAdmissions), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ); + let parent = service + .prepare_execution(service.authenticate("sk_parent").unwrap()) + .await + .unwrap(); + let previous = parent.request_settings().execution_values().unwrap(); + let configuration = RequestSettings::new(snapshots.acquire().unwrap()) + .with_execution(parent.client().policy(), previous.timeout_ms); + let mut values = previous.clone(); + values.disable_fast = false; + values.client_limits = RateLimits::unlimited(); + values.timeout_ms = 90_000; + let mut runtime = serde_json::to_value(&values.runtime).unwrap(); + runtime["model_mappings"] = json!({"child-alias":"model"}); + values.runtime = serde_json::from_value(runtime).unwrap(); + let changed = configuration + .replace_execution(&values, "settings-plugin") + .unwrap(); + for (token, expected_limit, expected_fast, expected_timeout, profile) in [ + ("sk_parent", 0, false, 90, "parent-default"), + ("sk_child", 9, true, 600, "child-default"), + ] { + let request = ClientAuthenticationRequest::bearer(token) + .unwrap() + .with_settings(changed.clone()); + let client = service.authenticate_request(request).await.unwrap(); + let mut prepared = service.prepare_execution(client).await.unwrap(); + let settings = prepared.request_settings(); + let compiled = settings.snapshot(); + prepared.apply_settings(&settings).unwrap(); + assert!(Arc::ptr_eq(prepared.client().snapshot(), &compiled)); + let actual = prepared.request_settings().execution_values().unwrap(); + assert_eq!( + prepared.request_settings().execution_values(), + Some(actual.clone()) + ); + assert_eq!(actual.client_limits.max_concurrency, expected_limit); + assert_eq!(actual.disable_fast, expected_fast); + assert_eq!(actual.timeout_ms, expected_timeout * 1000); + let facts = serde_json::to_value(actual.runtime).unwrap(); + assert_eq!(facts["request_profiles"]["openai"]["identity"], profile); + assert_eq!(facts["model_mappings"]["child-alias"], "model"); + } + assert!( + changed.inspect()["overrides"] + .get("request_profiles") + .is_none() + ); + assert_eq!( + changed.inspect()["execution"]["client_limits"]["value"]["max_concurrency"], + 0 + ); + assert!( + configuration.inspect()["overrides"] + .as_object() + .unwrap() + .is_empty() + ); + }); +} diff --git a/backend/crates/gateway-core/tests/engine/middleware.rs b/backend/crates/gateway-core/tests/engine/middleware.rs index 10f442a33..fcc4f0ecb 100644 --- a/backend/crates/gateway-core/tests/engine/middleware.rs +++ b/backend/crates/gateway-core/tests/engine/middleware.rs @@ -19,7 +19,6 @@ use gateway_core::engine::middleware::{ use gateway_core::engine::provider::{ ProviderCallMetadata, ProviderMiddlewareTerminal, ProviderStream, execute_attempt_middleware, }; -use gateway_core::error::{ProviderError, ProviderErrorKind}; use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ResponseMeta}; use gateway_core::identity::ProviderKind; use gateway_core::lifecycle::CancellationToken; @@ -27,7 +26,7 @@ use gateway_core::operation::{GenerateRequest, Operation, OperationKind, Protoco use gateway_core::policy::ClientApiKeyId; use gateway_core::routing::UpstreamModelId; use gateway_core::runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}; -use gateway_core::upstream::{UpstreamSendState, UpstreamTransport}; +use gateway_core::upstream::UpstreamTransport; use serde_json::{Value, json}; #[derive(Debug)] @@ -40,7 +39,7 @@ impl MiddlewarePlan for RewritingPlan { &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Attempt); assert_eq!(context.attempt_index(), NonZeroU32::new(1)); @@ -427,7 +426,7 @@ impl MiddlewarePlan for AddingHeaderPlan { &self, _context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { let added_headers = self.headers.clone(); Box::pin(async move { @@ -505,7 +504,7 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() { } #[test] -fn attempt_middleware_forwards_business_headers_but_rejects_authentication_headers() { +fn attempt_middleware_preserves_multi_value_and_authentication_headers() { futures::executor::block_on(async { let allowed_calls = Arc::new(AtomicUsize::new(0)); let allowed_terminal: ProviderMiddlewareTerminal = { @@ -544,39 +543,37 @@ fn attempt_middleware_forwards_business_headers_but_rejects_authentication_heade .unwrap(); assert_eq!(allowed_calls.load(Ordering::SeqCst), 1); - let rejected_calls = Arc::new(AtomicUsize::new(0)); - let rejected_terminal: ProviderMiddlewareTerminal = { - let rejected_calls = Arc::clone(&rejected_calls); - Box::new(move |_operation, _headers| { - rejected_calls.fetch_add(1, Ordering::SeqCst); - Box::pin(async { - Err(ProviderError::new( - ProviderErrorKind::Unavailable, - UpstreamSendState::NotSent, - )) + let calls = Arc::new(AtomicUsize::new(0)); + let terminal: ProviderMiddlewareTerminal = { + let calls = calls.clone(); + Box::new(move |_, headers| { + calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + assert_eq!(headers[0].name(), "authorization"); + assert_eq!( + headers[0].value(), + &Bytes::from_static(b"Bearer fixture-override") + ); + Ok(empty_provider_stream()) }) }) }; - let rejected = header_plan( + let plan = header_plan( "middleware-authentication-header", vec![MiddlewareHeader::new( "authorization", - Bytes::from_static(b"Bearer forbidden"), + Bytes::from_static(b"Bearer fixture-override"), )], ); - - let error = execute_attempt_middleware( - Some(&rejected), + execute_attempt_middleware( + Some(&plan), context(), operation(), ClientTransport::HttpJson, - rejected_terminal, + terminal, ) .await - .err() - .expect("protected headers must fail before the Provider terminal"); - assert_eq!(error.kind(), ProviderErrorKind::Protocol); - assert_eq!(error.send_state(), UpstreamSendState::NotSent); - assert_eq!(rejected_calls.load(Ordering::SeqCst), 0); + .unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); }); } diff --git a/backend/crates/gateway-core/tests/main.rs b/backend/crates/gateway-core/tests/main.rs index e2fe497ef..427570963 100644 --- a/backend/crates/gateway-core/tests/main.rs +++ b/backend/crates/gateway-core/tests/main.rs @@ -7,10 +7,12 @@ mod event; mod health; mod lifecycle; mod metering; +mod middleware; mod operation; mod policy; mod provider_ports; mod routing; mod runtime; +mod settings; mod task; mod upstream; diff --git a/backend/crates/gateway-core/tests/middleware/mod.rs b/backend/crates/gateway-core/tests/middleware/mod.rs new file mode 100644 index 000000000..fdada259c --- /dev/null +++ b/backend/crates/gateway-core/tests/middleware/mod.rs @@ -0,0 +1,56 @@ +use std::sync::{Arc, Mutex}; + +use gateway_core::middleware::{Middleware, compose}; + +#[test] +fn onion_preserves_typed_input_and_reverse_return_order() { + futures::executor::block_on(async { + let events = Arc::new(Mutex::new(Vec::new())); + let layers = (1..=3) + .map(|index| -> Middleware, Vec, &'static str> { + let events = events.clone(); + Box::new(move |mut input, next| { + Box::pin(async move { + events.lock().unwrap().push(index); + input.push(index); + let mut output = next.run(input).await?; + events.lock().unwrap().push(10 + index); + output.push(10 + index); + Ok(output) + }) + }) + }) + .collect(); + let output = compose(layers, |input| Box::pin(async { Ok(input) })) + .run(vec![0]) + .await + .unwrap(); + assert_eq!(output, [0, 1, 2, 3, 13, 12, 11]); + assert_eq!(*events.lock().unwrap(), [1, 2, 3, 13, 12, 11]); + }); +} + +#[test] +fn short_circuit_drops_unconsumed_terminal_and_recovers_outer_error() { + futures::executor::block_on(async { + let resource = Arc::new(()); + let retained = resource.clone(); + let layers: Vec> = vec![ + Box::new(|input, next| { + Box::pin(async move { + assert_eq!(next.run(input).await, Err("rejected")); + Ok(204) + }) + }), + Box::new(|_, _next| Box::pin(async { Err("rejected") })), + ]; + let result = compose(layers, move |()| { + drop(retained); + panic!("短路不能调用终端") + }) + .run(()) + .await; + assert_eq!(result, Ok(204)); + assert_eq!(Arc::strong_count(&resource), 1); + }); +} diff --git a/backend/crates/gateway-core/tests/routing/mod.rs b/backend/crates/gateway-core/tests/routing/mod.rs index 28dac1b51..e53b226a4 100644 --- a/backend/crates/gateway-core/tests/routing/mod.rs +++ b/backend/crates/gateway-core/tests/routing/mod.rs @@ -1,10 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::num::NonZeroU32; use std::sync::Arc; -use std::time::Duration; use bytes::Bytes; -use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy}; +use gateway_core::account::ProviderAccountId; use gateway_core::operation::{ CapabilityRequirements, Feature, GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, ProtocolPayload, ProviderHttpMethod, ProviderHttpRequest, RawHttpPayload, @@ -20,12 +18,8 @@ use gateway_core::routing::{ mod snapshot; -fn scheduling() -> AccountSelectionPolicy { - AccountSelectionPolicy::new( - RotationStrategy::Smart, - NonZeroU32::new(3).expect("positive"), - Duration::from_millis(50), - ) +fn settings() -> gateway_core::settings::SettingsValues { + gateway_core::settings::SettingsValues::new(3, 50, "smart", BTreeMap::new(), None, None) } fn capabilities() -> ModelCapabilities { @@ -100,9 +94,9 @@ fn client_policy(id: &str, plaintext: &str, enabled: bool) -> ClientPolicy { } fn snapshot() -> RuntimeSnapshot { - RuntimeSnapshot::new( + let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![ ProviderKind::new("openai").expect("provider"), ProviderKind::new("xai").expect("provider"), @@ -114,11 +108,15 @@ fn snapshot() -> RuntimeSnapshot { Vec::new(), ) .expect("snapshot") - .with_account_directory(account_directory()) - .with_model_mappings(BTreeMap::from([ - ("gpt-5.4".to_owned(), "gpt-5.5".to_owned()), - ("grok-latest".to_owned(), "grok-4.5".to_owned()), - ])) + .with_account_directory(account_directory()); + let settings = snapshot + .settings() + .clone() + .with_model_mappings(BTreeMap::from([ + ("gpt-5.4".to_owned(), "gpt-5.5".to_owned()), + ("grok-latest".to_owned(), "grok-4.5".to_owned()), + ])); + snapshot.with_settings(&settings).unwrap() } #[test] @@ -216,7 +214,7 @@ fn restricted_scope_with_no_enabled_group_should_fail_closed() { fn snapshot_should_publish_only_enabled_plaintext_client_policies() { let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![ProviderKind::new("openai").expect("provider")], Vec::new(), vec![ @@ -241,7 +239,7 @@ fn snapshot_should_publish_only_enabled_plaintext_client_policies() { fn snapshot_should_reject_model_for_missing_provider() { let result = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), Vec::new(), vec![model("missing", "gpt-5.5", capabilities())], Vec::new(), @@ -254,7 +252,7 @@ fn snapshot_should_reject_model_for_missing_provider() { fn snapshot_should_reject_duplicate_provider_model() { let result = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![ProviderKind::new("openai").expect("provider")], vec![ model("openai", "gpt-5.5", capabilities()), @@ -294,20 +292,30 @@ fn selected_provider_should_use_global_model_mapping() { #[test] fn model_mapping_should_follow_a_bounded_alias_chain() { - let snapshot = snapshot().with_model_mappings(BTreeMap::from([ - ("public-model".to_owned(), "compat-model".to_owned()), - ("compat-model".to_owned(), "gpt-5.5".to_owned()), - ])); + let snapshot = snapshot(); + let settings = snapshot + .settings() + .clone() + .with_model_mappings(BTreeMap::from([ + ("public-model".to_owned(), "compat-model".to_owned()), + ("compat-model".to_owned(), "gpt-5.5".to_owned()), + ])); + let snapshot = snapshot.with_settings(&settings).unwrap(); assert_eq!(snapshot.mapped_model("public-model"), "gpt-5.5"); } #[test] fn cyclic_model_mapping_should_fall_back_to_the_original_name() { - let snapshot = snapshot().with_model_mappings(BTreeMap::from([ - ("first".to_owned(), "second".to_owned()), - ("second".to_owned(), "first".to_owned()), - ])); + let snapshot = snapshot(); + let settings = snapshot + .settings() + .clone() + .with_model_mappings(BTreeMap::from([ + ("first".to_owned(), "second".to_owned()), + ("second".to_owned(), "first".to_owned()), + ])); + let snapshot = snapshot.with_settings(&settings).unwrap(); assert_eq!(snapshot.mapped_model("first"), "first"); } @@ -397,7 +405,7 @@ fn blocked_model_provider_should_not_be_misreported_as_model_not_found() { fn blocked_unknown_catalog_should_not_prove_model_absence() { let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![ ProviderKind::new("openai").expect("provider"), ProviderKind::new("xai").expect("provider"), @@ -544,7 +552,7 @@ fn token_count_endpoint_should_require_exact_model_capability_and_scope() { ); let capable = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![provider.clone()], vec![ProviderModel::new( provider.clone(), @@ -623,7 +631,7 @@ fn provider_http_endpoint_should_be_provider_scoped_without_a_model_binding() { fn unmapped_model_should_pass_through_unchanged() { let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![ProviderKind::new("openai").expect("provider")], Vec::new(), Vec::new(), @@ -660,10 +668,15 @@ fn unmapped_model_should_pass_through_unchanged() { #[test] fn model_mapping_should_use_exact_client_keys() { - let snapshot = snapshot().with_model_mappings(BTreeMap::from([( - " exact-alias ".to_owned(), - "gpt-5.5".to_owned(), - )])); + let snapshot = snapshot(); + let settings = snapshot + .settings() + .clone() + .with_model_mappings(BTreeMap::from([( + " exact-alias ".to_owned(), + "gpt-5.5".to_owned(), + )])); + let snapshot = snapshot.with_settings(&settings).unwrap(); assert_eq!(snapshot.mapped_model(" exact-alias "), "gpt-5.5"); assert_eq!(snapshot.mapped_model("exact-alias"), "exact-alias"); @@ -694,7 +707,7 @@ fn blocked_provider_should_be_filtered() { fn known_unsupported_operation_should_not_be_bypassed() { let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - scheduling(), + settings(), vec![ProviderKind::new("openai").expect("provider")], vec![model( "openai", diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 60c3bd23c..d21884925 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -10,7 +10,7 @@ use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}; use gateway_core::routing::snapshot::{ RuntimeSnapshotCompileError, RuntimeSnapshotCompiler, SnapshotAccountGroupFacts, SnapshotAccountGroupMemberFacts, SnapshotClientPolicyFacts, SnapshotFacts, - SnapshotProviderAccountFacts, SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort, + SnapshotProviderAccountFacts, SnapshotStoreError, SnapshotStorePort, }; use gateway_core::routing::{ ConfigRevision, ContributedModelAlias, ModelCapabilities, ModelPresentation, @@ -21,6 +21,7 @@ use gateway_core::runtime::extensions::{ ExtensionPreparationError, ExtensionPreparationPort, ExtensionSetId, ExtensionSetLease, ExtensionSetReference, }; +use gateway_core::settings::SettingsValues; #[derive(Clone)] struct TestSnapshotStore { @@ -130,7 +131,7 @@ fn compile_aliases( let facts = SnapshotFacts::new( revision(1), revision(1), - SnapshotSettingsFacts::new( + SettingsValues::new( 3, 50, "smart", @@ -330,7 +331,7 @@ fn compiler_accepts_unlimited_default_account_concurrency() { let facts = SnapshotFacts::new( revision(1), revision(1), - SnapshotSettingsFacts::new(0, 50, "smart", BTreeMap::new(), None, None), + SettingsValues::new(0, 50, "smart", BTreeMap::new(), None, None), Vec::new(), Vec::new(), vec![SnapshotProviderAccountFacts::new( @@ -374,7 +375,7 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() { let facts = SnapshotFacts::new( revision(index as u64 + 1), revision(index as u64 + 1), - SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None) + SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None) .with_smart_scheduling(config), vec![], vec![], @@ -422,7 +423,7 @@ fn withdrawn_provider_keeps_accounts_without_becoming_a_route_or_expanding_group let facts = SnapshotFacts::new( revision(1), revision(1), - SnapshotSettingsFacts::new( + SettingsValues::new( 3, 0, "smart", @@ -512,7 +513,12 @@ fn routing_plans_share_frozen_pricing_after_a_new_snapshot_is_published() { ) }; let original = prices(12500); - let handle = RuntimeSnapshotHandle::new(super::snapshot().with_pricing(original.clone())); + let snapshot = super::snapshot(); + let handle = RuntimeSnapshotHandle::new( + snapshot + .with_settings(&snapshot.settings().clone().with_pricing(original.clone())) + .unwrap(), + ); let frozen = handle.acquire().unwrap(); let plan = |snapshot: &gateway_core::routing::RuntimeSnapshot| { snapshot @@ -525,7 +531,11 @@ fn routing_plans_share_frozen_pricing_after_a_new_snapshot_is_published() { .unwrap() }; let old_plan = plan(&frozen); - handle.publish(super::snapshot().with_pricing(prices(20000))); + handle.publish( + snapshot + .with_settings(&snapshot.settings().clone().with_pricing(prices(20000))) + .unwrap(), + ); let new_plan = plan(&handle.acquire().unwrap()); assert!(Arc::ptr_eq(&old_plan.pricing(), &original)); assert!(Arc::ptr_eq(&plan(&frozen).pricing(), &original)); @@ -766,7 +776,7 @@ fn facts_with_min_versions( SnapshotFacts::new( revision(config_revision), revision(observed_current_revision), - SnapshotSettingsFacts::new( + SettingsValues::new( 3, 50, "smart", @@ -810,7 +820,7 @@ fn global_request_location_should_be_frozen_when_snapshot_is_published() { let facts = SnapshotFacts::new( revision(version), revision(version), - SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None) + SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None) .with_request_location(location, enabled), Vec::new(), Vec::new(), @@ -887,7 +897,7 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication() let facts = SnapshotFacts::new( revision(version), revision(version), - SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None) + SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None) .with_responses_max_decompressed_body_bytes(bytes), Vec::new(), Vec::new(), @@ -940,7 +950,7 @@ fn disable_fast_uses_only_bound_groups_without_changing_account_scope() { let facts = SnapshotFacts::new( revision(1), revision(1), - SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None), + SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None), vec![SnapshotClientPolicyFacts::new( ClientApiKeyId::new("key_fast_policy").unwrap(), PlaintextClientApiKey::new("sk_fast_policy").unwrap(), @@ -1020,7 +1030,7 @@ fn key_profiles_replace_whole_global_choice_and_previous_snapshot_stays_frozen() }; let build = |global, overridden| { let profiles = BTreeMap::from([(provider.clone(), document(global))]); - let settings = SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None) + let settings = SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None) .with_request_profiles(profiles); let inherited = SnapshotClientPolicyFacts::new( ClientApiKeyId::new("key_inherited").unwrap(), @@ -1054,10 +1064,15 @@ fn key_profiles_replace_whole_global_choice_and_previous_snapshot_stays_frozen() .unwrap() }; let values = |snapshot: &gateway_core::routing::RuntimeSnapshot| { + let settings = gateway_core::settings::RequestSettings::new(Arc::new(snapshot.clone())); let mut values: Vec<_> = snapshot .client_policies() .map(|policy| { - policy + if policy.key_id().as_str() == "key_inherited" { + assert!(policy.defaults().request_profiles.is_empty()); + } + settings + .apply_policy(policy.clone()) .account_scope() .request_profile(&provider) .unwrap() diff --git a/backend/crates/gateway-core/tests/runtime/mod.rs b/backend/crates/gateway-core/tests/runtime/mod.rs index 58b65ac39..ede4c083b 100644 --- a/backend/crates/gateway-core/tests/runtime/mod.rs +++ b/backend/crates/gateway-core/tests/runtime/mod.rs @@ -1,21 +1,19 @@ use std::collections::BTreeMap; mod extensions; -use std::num::NonZeroU32; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::Duration; use futures::channel::{mpsc, oneshot}; use futures::executor::block_on; use futures::future::BoxFuture; -use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy}; +use gateway_core::account::ProviderAccountId; use gateway_core::lifecycle::CancellationToken; use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}; use gateway_core::routing::snapshot::{ RuntimeSnapshotCompiler, SnapshotAccountGroupFacts, SnapshotAccountGroupMemberFacts, - SnapshotClientPolicyFacts, SnapshotFacts, SnapshotProviderAccountFacts, SnapshotSettingsFacts, - SnapshotStoreError, SnapshotStorePort, + SnapshotClientPolicyFacts, SnapshotFacts, SnapshotProviderAccountFacts, SnapshotStoreError, + SnapshotStorePort, }; use gateway_core::routing::{ AccountGroupId, ConfigRevision, ModelCapabilities, ProviderCatalogGeneration, @@ -26,6 +24,7 @@ use gateway_core::runtime::{ RuntimeSnapshotHandle, RuntimeSnapshotPublisher, SnapshotControl, SnapshotRevisionStream, SnapshotSubscriptionError, SnapshotSubscriptionPort, runtime_revision_needs_refresh, }; +use gateway_core::settings::SettingsValues; use gateway_core::task::{ ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerKind, WorkerRunnable, }; @@ -739,7 +738,7 @@ fn scoped_facts(value: u64, allow_removed: bool) -> SnapshotFacts { SnapshotFacts::new( revision(value), revision(value), - SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None), + SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None), vec![SnapshotClientPolicyFacts::new( ClientApiKeyId::new("key_audit_synthetic").expect("key ID"), PlaintextClientApiKey::new("sk_audit_synthetic_not_a_real_key").expect("synthetic key"), @@ -772,7 +771,7 @@ fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts SnapshotFacts::new( revision(config_revision), revision(observed_current_revision), - SnapshotSettingsFacts::new( + SettingsValues::new( 3, 50, "smart", @@ -799,11 +798,7 @@ fn compiler(store: Arc) -> RuntimeSnapshotCompiler { fn empty_snapshot(value: u64) -> RuntimeSnapshot { RuntimeSnapshot::new( revision(value), - AccountSelectionPolicy::new( - RotationStrategy::Smart, - NonZeroU32::new(1).expect("positive concurrency"), - Duration::ZERO, - ), + gateway_core::settings::SettingsValues::new(1, 0, "smart", Default::default(), None, None), Vec::new(), Vec::new(), Vec::new(), diff --git a/backend/crates/gateway-core/tests/settings/mod.rs b/backend/crates/gateway-core/tests/settings/mod.rs new file mode 100644 index 000000000..00ff1aead --- /dev/null +++ b/backend/crates/gateway-core/tests/settings/mod.rs @@ -0,0 +1,189 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use gateway_core::{ + account::OpaqueProviderData, + identity::ProviderKind, + policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits}, + routing::{ClientRoutingScope, ConfigRevision, FrozenAccountScope, RuntimeSnapshot}, + settings::{RequestSettings, SettingsValues}, +}; +use serde_json::json; + +fn profiles(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(provider, name)| { + ( + ProviderKind::new(*provider).unwrap(), + OpaqueProviderData::new(json!({"identity":name}).as_object().unwrap().clone()), + ) + }) + .collect() +} + +fn snapshot(revision: u64, name: &str) -> Arc { + Arc::new( + RuntimeSnapshot::new( + ConfigRevision::new(revision).unwrap(), + SettingsValues::new(3, 50, "smart", BTreeMap::new(), None, None) + .with_request_profiles(profiles(&[("openai", name), ("xai", name)])), + vec![], + vec![], + vec![], + ) + .unwrap(), + ) +} + +fn policy(id: &str) -> ClientPolicy { + ClientPolicy::new( + ClientApiKeyId::new(id).unwrap(), + PlaintextClientApiKey::new(format!("fixture-{id}")).unwrap(), + Arc::new( + FrozenAccountScope::new(Arc::default(), ClientRoutingScope::all_accounts()) + .with_request_profiles(profiles(&[("openai", id)])) + .with_disable_fast(true), + ), + true, + RateLimits { + max_concurrency: 3, + requests_per_minute: 10, + }, + ) +} + +#[test] +fn settings_facts_are_shared_and_invalid_compilation_does_not_change_them() { + let original = snapshot(1, "host"); + let frozen = original.as_ref().clone(); + assert!(std::ptr::eq(original.settings(), frozen.settings())); + let facts = serde_json::to_value(original.settings()).unwrap(); + let changed = original + .with_settings( + &original + .settings() + .clone() + .with_model_mappings(BTreeMap::from([("alias".into(), "model".into())])), + ) + .unwrap(); + assert_eq!(changed.mapped_model("alias"), "model"); + assert_eq!(original.mapped_model("alias"), "alias"); + assert_eq!(serde_json::to_value(original.settings()).unwrap(), facts); + assert!( + original + .with_settings( + &original + .settings() + .clone() + .with_responses_max_decompressed_body_bytes(0) + ) + .is_err() + ); + assert!(std::ptr::eq(original.settings(), frozen.settings())); +} + +#[test] +fn resolving_an_existing_policy_uses_key_defaults_and_the_current_host() { + let key = policy("key"); + let first = RequestSettings::new(snapshot(1, "first")).apply_policy(key.clone()); + assert_eq!( + first.account_scope().request_profiles(), + &profiles(&[("openai", "key"), ("xai", "first")]) + ); + assert_eq!( + first.defaults().request_profiles, + key.defaults().request_profiles + ); + let second = RequestSettings::new(snapshot(2, "second")).apply_policy(first); + assert_eq!( + second.account_scope().request_profiles(), + &profiles(&[("openai", "key"), ("xai", "second")]) + ); + assert_eq!( + second.defaults().request_profiles, + profiles(&[("openai", "key")]) + ); +} + +#[test] +fn scope_changes_recompute_defaults_and_inherit_only_explicit_overrides() { + let parent = policy("parent"); + let child = policy("child"); + let original = RequestSettings::new(snapshot(1, "first")).with_execution(&parent, 60_000); + let mut values = original.execution_values().unwrap(); + values.runtime = values + .runtime + .with_model_mappings(BTreeMap::from([("alias".into(), "model".into())])); + values.disable_fast = false; + values.client_limits = RateLimits::unlimited(); + values.timeout_ms = 90_000; + let changed = original.replace_execution(&values, "plugin").unwrap(); + assert!( + changed.inspect()["overrides"] + .get("request_profiles") + .is_none() + ); + let rebased = changed.rebase(snapshot(2, "second")).unwrap(); + let parent_values = rebased.execution_values().unwrap(); + assert_eq!( + parent_values.runtime.request_profiles(), + &profiles(&[("openai", "parent"), ("xai", "second")]) + ); + assert!(!parent_values.disable_fast); + let child_settings = rebased.with_execution(&child, 60_000); + let child_values = child_settings.execution_values().unwrap(); + assert_eq!( + child_values.runtime.request_profiles(), + &profiles(&[("openai", "child"), ("xai", "second")]) + ); + assert!(child_values.disable_fast); + assert_eq!(child_values.client_limits, child.limits()); + assert_eq!(child_values.timeout_ms, 60_000); + assert_eq!(child_settings.snapshot().mapped_model("alias"), "model"); + assert_eq!(original.snapshot().mapped_model("alias"), "alias"); + assert_eq!(original.execution_values().unwrap().timeout_ms, 60_000); +} + +#[test] +fn explicitly_selecting_the_host_profiles_overrides_keys_after_rebase() { + let host = snapshot(1, "first"); + let settings = RequestSettings::new(host.clone()).with_execution(&policy("parent"), 60_000); + let mut values = settings.execution_values().unwrap(); + values.runtime = values + .runtime + .with_request_profiles(host.settings().request_profiles().clone()); + let changed = settings.replace_execution(&values, "plugin").unwrap(); + assert_eq!( + changed.inspect()["overrides"]["request_profiles"]["instance_id"], + "plugin" + ); + let changed = changed + .rebase(snapshot(2, "second")) + .unwrap() + .with_execution(&policy("child"), 60_000); + let values = changed.execution_values().unwrap(); + let effective_policy = changed.apply_policy(policy("child")); + assert_eq!( + values.runtime.request_profiles(), + host.settings().request_profiles() + ); + assert_eq!( + effective_policy.account_scope().request_profiles(), + values.runtime.request_profiles() + ); +} + +#[test] +fn changing_only_limits_reuses_the_resolved_account_scope() { + let settings = RequestSettings::new(snapshot(1, "host")); + let policy = settings.apply_policy(policy("key")); + let scope = policy.account_scope().clone(); + let settings = settings.with_execution(&policy, 60_000); + let mut values = settings.execution_values().unwrap(); + values.client_limits = RateLimits::unlimited(); + let settings = settings.replace_execution(&values, "plugin").unwrap(); + let resolved = settings.apply_policy(policy); + assert!(Arc::ptr_eq(resolved.account_scope(), &scope)); + assert_eq!(resolved.limits(), RateLimits::unlimited()); + assert_eq!(resolved.defaults().limits.max_concurrency, 3); +} diff --git a/backend/crates/gateway-host/src/outbound/network.rs b/backend/crates/gateway-host/src/outbound/network.rs index 12327d7cb..65f9aa62f 100644 --- a/backend/crates/gateway-host/src/outbound/network.rs +++ b/backend/crates/gateway-host/src/outbound/network.rs @@ -12,9 +12,19 @@ use super::{HttpError, HttpErrorKind}; #[derive(Clone, Default)] pub struct NetworkPolicy { additional: Vec, + unrestricted: bool, } impl NetworkPolicy { + /// 完整信任的宿主调用方可以访问任意地址;仍使用相同的 DNS 与传输实现。 + #[must_use] + pub const fn unrestricted() -> Self { + Self { + additional: Vec::new(), + unrestricted: true, + } + } + pub fn new(networks: &[String]) -> Result { if networks.len() > 32 { return Err(HttpError::invalid("network limits")); @@ -27,7 +37,10 @@ impl NetworkPolicy { .map_err(|_| HttpError::invalid("network range")) }) .collect::>()?; - Ok(Self { additional }) + Ok(Self { + additional, + unrestricted: false, + }) } #[must_use] @@ -38,7 +51,8 @@ impl NetworkPolicy { .map_or(IpAddr::V6(address), IpAddr::V4), other => other, }; - public_address(address) + self.unrestricted + || public_address(address) || self .additional .iter() diff --git a/backend/crates/gateway-host/src/outbound/websocket.rs b/backend/crates/gateway-host/src/outbound/websocket.rs index 8200f53a4..ad8c897cd 100644 --- a/backend/crates/gateway-host/src/outbound/websocket.rs +++ b/backend/crates/gateway-host/src/outbound/websocket.rs @@ -1,10 +1,13 @@ use std::time::Duration; use bytes::Bytes; -use futures::{SinkExt as _, StreamExt as _}; +use futures::{ + SinkExt as _, StreamExt as _, + stream::{SplitSink, SplitStream}, +}; use gateway_core::account::OutboundProxy; use hyper_util::rt::TokioIo; -use tokio::sync::OwnedSemaphorePermit; +use tokio::sync::{Mutex, OwnedSemaphorePermit, watch}; use tokio_tungstenite::{ WebSocketStream, tungstenite::{ @@ -18,10 +21,13 @@ use super::{HttpBody, HttpClient, HttpError, HttpErrorKind, HttpRequest, Network const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024; -/// 无内容型 Debug,连接由持有者独占;丢弃时关闭底层连接并释放容量。 +type Socket = WebSocketStream>; + +/// 读写分别串行,允许等待输出时发送控制消息;关闭会唤醒在途操作并释放容量。 pub struct ManagedWebSocket { - socket: WebSocketStream>, - _permit: OwnedSemaphorePermit, + writer: Mutex, OwnedSemaphorePermit)>>, + reader: Mutex>>, + closed: watch::Sender, } pub enum WebSocketMessage { @@ -101,12 +107,14 @@ impl HttpClient { let socket = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Client, Some(config)) .await; + let (writer, reader) = socket.split(); Ok(WebSocketResponse { status: response.status, headers: response.headers, connection: Some(ManagedWebSocket { - socket, - _permit: response.permit, + writer: Mutex::new(Some((writer, response.permit))), + reader: Mutex::new(Some(reader)), + closed: watch::channel(false).0, }), body: None, }) @@ -115,7 +123,7 @@ impl HttpClient { impl ManagedWebSocket { pub async fn send( - &mut self, + &self, message: WebSocketMessage, timeout: Duration, ) -> Result<(), HttpError> { @@ -128,36 +136,78 @@ impl ManagedWebSocket { } _ => return Err(HttpError::invalid("WebSocket message limits")), }; - tokio::time::timeout(timeout, self.socket.send(message)) - .await - .map_err(|_| timeout_error())? - .map_err(|_| HttpError::sent("WebSocket send")) + let mut closed = self.closed.subscribe(); + let result = tokio::select! { + biased; + _ = closed.wait_for(|closed| *closed) => Err(HttpError::sent("WebSocket closed")), + result = tokio::time::timeout(timeout, async { + let mut writer = self.writer.lock().await; + let (writer, _) = writer.as_mut().ok_or_else(|| HttpError::invalid("WebSocket closed"))?; + writer.send(message).await.map_err(|_| HttpError::sent("WebSocket send")) + }) => result.unwrap_or_else(|_| Err(timeout_error())), + }; + if result.is_err() { + self.close().await; + } + result } - pub async fn read(&mut self, timeout: Duration) -> Result, HttpError> { - tokio::time::timeout(timeout, async { - loop { - match self.socket.next().await { - Some(Ok(Message::Text(text))) => { - return Ok(Some(WebSocketMessage::Text(text.to_string()))); - } - Some(Ok(Message::Binary(bytes))) => { - return Ok(Some(WebSocketMessage::Binary(bytes))); - } - Some(Ok(Message::Ping(_))) => self - .socket + pub async fn read(&self, timeout: Duration) -> Result, HttpError> { + let mut closed = self.closed.subscribe(); + let result = tokio::select! { + biased; + _ = closed.wait_for(|closed| *closed) => Ok(None), + result = tokio::time::timeout(timeout, self.receive()) => result.unwrap_or_else(|_| Err(timeout_error())), + }; + if !matches!(result, Ok(Some(_))) { + self.close().await; + } + result + } + + async fn receive(&self) -> Result, HttpError> { + let mut reader = self.reader.lock().await; + let Some(reader) = reader.as_mut() else { + return Ok(None); + }; + loop { + match reader.next().await { + Some(Ok(Message::Text(text))) => { + return Ok(Some(WebSocketMessage::Text(text.to_string()))); + } + Some(Ok(Message::Binary(bytes))) => { + return Ok(Some(WebSocketMessage::Binary(bytes))); + } + Some(Ok(Message::Ping(_))) => { + let mut writer = self.writer.lock().await; + let Some((writer, _)) = writer.as_mut() else { + return Ok(None); + }; + writer .flush() .await - .map_err(|_| HttpError::sent("WebSocket heartbeat"))?, - Some(Ok(Message::Pong(_))) => {} - Some(Ok(Message::Close(_))) | None => return Ok(None), - Some(Ok(Message::Frame(_))) => return Err(HttpError::sent("WebSocket frame")), - Some(Err(_)) => return Err(HttpError::sent("WebSocket receive")), + .map_err(|_| HttpError::sent("WebSocket heartbeat"))?; } + Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Ok(Message::Frame(_))) => return Err(HttpError::sent("WebSocket frame")), + Some(Err(_)) => return Err(HttpError::sent("WebSocket receive")), } - }) - .await - .map_err(|_| timeout_error())? + } + } + + #[must_use] + pub fn is_closed(&self) -> bool { + *self.closed.borrow() + } + + /// 先取消在途读写,再释放两端;返回时连接与容量均已回收。 + pub async fn close(&self) { + self.closed.send_replace(true); + let mut writer = self.writer.lock().await; + let mut reader = self.reader.lock().await; + reader.take(); + writer.take(); } } diff --git a/backend/crates/gateway-host/tests/outbound/websocket.rs b/backend/crates/gateway-host/tests/outbound/websocket.rs index 2470c7af1..9a4622182 100644 --- a/backend/crates/gateway-host/tests/outbound/websocket.rs +++ b/backend/crates/gateway-host/tests/outbound/websocket.rs @@ -56,7 +56,7 @@ async fn websocket_preserves_frames_handles_ping_and_releases_connection_on_drop .await .unwrap(); assert_eq!(response.status, 101); - let mut websocket = response.connection.take().unwrap(); + let websocket = response.connection.take().unwrap(); websocket .send( WebSocketMessage::Text("first".into()), @@ -77,6 +77,123 @@ async fn websocket_preserves_frames_handles_ping_and_releases_connection_on_drop .unwrap(); } +#[tokio::test] +async fn websocket_can_send_control_while_waiting_for_a_response() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/responses", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + assert_eq!( + socket.next().await.unwrap().unwrap(), + Message::Text("control".into()) + ); + socket + .send(Message::Text("acknowledged".into())) + .await + .unwrap(); + }); + let websocket = HttpClient::new() + .unwrap() + .open_websocket(request(url), None, &network(), Duration::from_secs(2)) + .await + .unwrap() + .connection + .unwrap(); + let read = websocket.read(Duration::from_secs(2)); + tokio::pin!(read); + assert!(futures::poll!(&mut read).is_pending()); + websocket + .send( + WebSocketMessage::Text("control".into()), + Duration::from_secs(2), + ) + .await + .unwrap(); + assert!( + matches!(read.await.unwrap(), Some(WebSocketMessage::Text(text)) if text == "acknowledged") + ); + server.await.unwrap(); +} + +#[tokio::test] +async fn websocket_close_wakes_pending_reads_and_releases_the_socket() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/responses", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + assert!(socket.next().await.is_none_or(|result| result.is_err())); + }); + let websocket = HttpClient::new() + .unwrap() + .open_websocket(request(url), None, &network(), Duration::from_secs(2)) + .await + .unwrap() + .connection + .unwrap(); + let read = websocket.read(Duration::from_secs(60)); + tokio::pin!(read); + assert!(futures::poll!(&mut read).is_pending()); + let (result, ()) = tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!(read, websocket.close()) + }) + .await + .unwrap(); + assert!(result.unwrap().is_none()); + assert!(websocket.is_closed()); + assert!( + websocket + .read(Duration::from_secs(1)) + .await + .unwrap() + .is_none() + ); + assert!( + websocket + .send( + WebSocketMessage::Text("late".into()), + Duration::from_secs(1) + ) + .await + .is_err() + ); + websocket.close().await; + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn websocket_read_timeout_closes_the_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/responses", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + assert!(socket.next().await.is_none_or(|result| result.is_err())); + }); + let websocket = HttpClient::new() + .unwrap() + .open_websocket(request(url), None, &network(), Duration::from_secs(2)) + .await + .unwrap() + .connection + .unwrap(); + let error = websocket + .read(Duration::from_millis(20)) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), HttpErrorKind::Timeout); + assert!(websocket.is_closed()); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); +} + #[tokio::test] async fn websocket_rejections_return_http_body_and_never_fall_back_from_proxy() { use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; diff --git a/backend/crates/gateway-plugin/runtime/Cargo.toml b/backend/crates/gateway-plugin/runtime/Cargo.toml index f19b07124..9ae76fe1c 100644 --- a/backend/crates/gateway-plugin/runtime/Cargo.toml +++ b/backend/crates/gateway-plugin/runtime/Cargo.toml @@ -14,6 +14,9 @@ bytes = "1.11.1" chrono = { version = "0.4.44", default-features = false, features = ["clock"] } flate2 = "1.1.9" futures = "0.3.32" +http = "1.5.0" +http-body = "1.0.1" +http-body-util = "0.1.5" gateway-admin = { path = "../../gateway-admin" } gateway-core = { path = "../../gateway-core" } gateway-host = { path = "../../gateway-host" } @@ -56,7 +59,7 @@ path = "tests/main.rs" [[bin]] name = "gateway-plugin-test-middleware" -path = "tests/support/middleware.rs" +path = "tests/support/middleware/mod.rs" test = false bench = false doc = false diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index c955b663c..07f0475cc 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -1,33 +1,18 @@ { - "schema_version": 1, - "manifest_schema_versions": [1], - "protocol_versions": [1], + "schema_version": 2, + "manifest_schema_versions": [2], + "protocol_versions": [2], "capabilities": [ { "capability": "frontend_authentication", "versions": [1] }, { "capability": "scheduler", "versions": [1] }, { "capability": "model_router", "versions": [1] }, { "capability": "model_catalog", "versions": [1] }, { "capability": "retry_policy", "versions": [1] }, - { "capability": "middleware", "versions": [1, 2] }, + { "capability": "middleware", "versions": [3] }, { "capability": "upstream_adapter", "versions": [1] }, - { "capability": "request_lifecycle", "versions": [1] }, - { "capability": "web_socket_observer", "versions": [1] }, - { "capability": "usage", "versions": [1] }, + { "capability": "observer", "versions": [1] }, { "capability": "command_line", "versions": [1] }, { "capability": "management", "versions": [1] }, { "capability": "maintenance", "versions": [1] } - ], - "permissions": [ - "network", - "upstream_connections", - "models", - "accounts", - "data", - "requests", - "public_endpoints", - "groups", - "keys", - "key_budgets", - "quota_observations" ] } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/management/callback.rs b/backend/crates/gateway-plugin/runtime/src/adapter/management/callback.rs index 5efac75e6..3a9cdf571 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/management/callback.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/management/callback.rs @@ -61,6 +61,10 @@ impl ManagementEntry { owner.update(admin_user_id.as_bytes()); } AdminPrincipal::ApiKey => owner.update(b"admin-api-key"), + AdminPrincipal::Plugin { instance_id } => { + owner.update(b"plugin:"); + owner.update(instance_id.as_bytes()); + } } owner.update(self.view.target.instance_id.as_bytes()); owner.update(command.path.as_bytes()); @@ -168,6 +172,16 @@ impl ManagementEntry { path: request.path, query: request.query, content_type: None, + headers: request + .headers + .iter() + .map( + |header| gateway_plugin_sdk::call::middleware::MiddlewareHeader { + name: header.name().to_owned(), + value: header.value().to_vec(), + }, + ) + .collect(), }) .map_err(|_| unavailable())?; let reply = self diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/management/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/management/mod.rs index 794f4feb4..c9534c1a0 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/management/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/management/mod.rs @@ -23,7 +23,6 @@ pub(crate) const MAXIMUM_BODY_BYTES: usize = 1024 * 1024; pub(crate) struct ManagementEntry { pub(crate) view: PluginManagementView, - pub(crate) models_authorized: bool, routes: Vec, resources: BTreeMap>, session: Arc, @@ -49,6 +48,7 @@ impl ManagementEntry { Ok(PluginManagementResponse { status: 200, content_type: resource.content_type.clone(), + headers: Vec::new(), body: body.clone(), }) } @@ -81,6 +81,16 @@ impl ManagementEntry { path: request.path, query: request.query, content_type: request.content_type, + headers: request + .headers + .iter() + .map( + |header| gateway_plugin_sdk::call::middleware::MiddlewareHeader { + name: header.name().to_owned(), + value: header.value().to_vec(), + }, + ) + .collect(), }) .map_err(|_| AdminError::invalid("插件管理请求无法编码"))?; let mut context = self @@ -102,7 +112,7 @@ impl ManagementEntry { } } -// 受保护 API 与公开回调共用响应合同,公开入口不能因采用另一阶段而放宽输出边界。 +// 管理 API 与公开回调共用类型和正文预算校验,完整响应头由 HTTP 入口解码。 fn decode_response( reply: RpcReply, allowed_content_types: &[String], @@ -122,6 +132,16 @@ fn decode_response( Ok(PluginManagementResponse { status: response.status, content_type: response.content_type, + headers: response + .headers + .into_iter() + .map(|header| { + gateway_core::engine::middleware::MiddlewareHeader::new( + header.name, + header.value.into(), + ) + }) + .collect(), body: reply.payload.into(), }) } 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 a6740076b..5d2a6556d 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/management/registration.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/management/registration.rs @@ -44,16 +44,6 @@ pub(crate) async fn prepare( let registration: ManagementRegistration = serde_json::from_slice(&reply.payload) .map_err(|_| AdminError::invalid("插件管理声明无效"))?; validation::registration(®istration)?; - if !registration.callbacks.is_empty() - && !instance - .grants - .iter() - .any(|grant| grant.permission == "public_endpoints") - { - return Err(AdminError::invalid( - "公开登录回调需要显式 public_endpoints 授权", - )); - } let mut resources = BTreeMap::new(); let mut resource_views = Vec::new(); let mut total = 0usize; @@ -63,16 +53,6 @@ pub(crate) async fn prepare( .get(&resource.path) .filter(|value| validation::content_type(value)) .ok_or_else(|| AdminError::invalid("管理资源未在包清单声明有效内容类型"))?; - if resource.public - && !instance - .grants - .iter() - .any(|grant| grant.permission == "public_endpoints") - { - return Err(AdminError::invalid( - "公开资源需要显式 public_endpoints 授权", - )); - } let bytes = package .resource(&resource.path) .ok_or_else(|| AdminError::invalid("管理资源不在已校验制品中"))?; @@ -141,10 +121,6 @@ pub(crate) async fn prepare( }; Ok(Some(ManagementEntry { view, - models_authorized: instance - .grants - .iter() - .any(|grant| grant.permission == "models"), routes, resources, session, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs b/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs index 90ac196e7..c00050cf9 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs @@ -18,13 +18,11 @@ use gateway_core::{ upstream::UpstreamSendState, }; use gateway_plugin_sdk::{ - Capability, Manifest, Permission, SendState, Stage, - call::{ - observation::ObserveWebSocketResponse, - policy::{ - ObserveRequest, RequestCost, RequestCostSource, RequestCostStatus, RequestFailure, - RequestMoney, RequestOutcome, RequestTerminal, RequestTimings, RequestUsage, - }, + Capability, Manifest, SendState, Stage, + call::observation::{ + Event, EventKind, RequestCompleted, RequestCost, RequestCostSource, RequestCostStatus, + RequestFailure, RequestMoney, RequestOutcome, RequestTerminal, RequestTimings, + RequestUsage, WebSocketResponse, }, }; use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc}; @@ -33,7 +31,6 @@ use crate::{RpcSession, adapter::scope::BindingScope}; const OBSERVATION_TIMEOUT: Duration = Duration::from_secs(2); const OBSERVATION_ENVELOPE_RESERVE: usize = 16 * 1024; -const MAX_WEBSOCKET_EVENT_TYPE_BYTES: usize = 256; pub(crate) struct ObserverEntry { order: i32, @@ -41,29 +38,66 @@ pub(crate) struct ObserverEntry { instance_id: String, session: Arc, callbacks: Arc, - lifecycle: Option, - usage: Option, + completed: Option, websocket: Option, - requests_authorized: bool, } impl ObserverEntry { - fn subscriptions(&self, observation: &RequestObservation) -> (bool, bool) { - ( - self.lifecycle - .as_ref() - .is_some_and(|scope| scope.matches_observation(observation)), - self.usage - .as_ref() - .is_some_and(|scope| scope.matches_observation(observation)), - ) + fn matches_completed(&self, observation: &RequestObservation) -> bool { + self.completed + .as_ref() + .is_some_and(|scope| scope.matches_observation(observation)) + } + + async fn observe( + &self, + event: Event, + payload: Vec, + extensions: gateway_core::engine::extensions::ExtensionCallScope, + timeout: Duration, + ) { + let request_id = event.request_id(); + let mut context = self.session.context(Stage::Observation, timeout); + context.request_id = Some(request_id.to_owned()); + let Ok(_scope) = self.callbacks.prepare_observation(&context, extensions) else { + return; + }; + let Ok(params) = serde_json::to_value(&event) else { + tracing::warn!( + plugin_id = self.plugin_id, + request_id, + "插件观察事件编码失败" + ); + return; + }; + match 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(_) => 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, "插件观察失败") + } + } } } struct CompiledBindings { order: i32, - lifecycle: Option, - usage: Option, + completed: Option, websocket: Option, } @@ -71,43 +105,38 @@ fn compile_bindings( manifest: &Manifest, bindings: &[PluginCapabilityBinding], ) -> Result, AdminError> { - let mut lifecycle = None; - let mut usage = None; + let mut completed = None; let mut websocket = None; let mut order = None; for binding in bindings { let capability = crate::contribution::resolve(manifest, binding)?.capability; - if !matches!( - capability, - Capability::RequestLifecycle | Capability::Usage | Capability::WebSocketObserver - ) { + if capability != Capability::Observer { continue; } let stage: Stage = serde_json::from_value(serde_json::Value::String(binding.stage.clone())) .map_err(|_| AdminError::invalid("插件观察阶段无效"))?; if stage != Stage::Observation || binding.failure_policy != PluginFailurePolicy::Observe { - return Err(AdminError::invalid( - "请求生命周期、用量与 WebSocket 观察绑定必须使用 observation/observe", - )); + return Err(AdminError::invalid("观察绑定必须使用 observation/observe")); } if order.is_some_and(|existing| existing != binding.order) { - return Err(AdminError::invalid( - "同一实例的请求生命周期、用量与 WebSocket 观察绑定必须使用相同顺序", - )); + return Err(AdminError::invalid("同一实例的观察绑定必须使用相同顺序")); } order = Some(binding.order); - let scope = BindingScope::compile(binding)?; - match capability { - Capability::RequestLifecycle => lifecycle = Some(scope), - Capability::Usage => usage = Some(scope), - Capability::WebSocketObserver => websocket = Some(scope), - _ => unreachable!("capability was filtered above"), + let event = binding.event.as_ref().and_then(|event| { + serde_json::from_value::(serde_json::Value::String(event.clone())).ok() + }); + let target = match event { + Some(EventKind::RequestCompleted) => &mut completed, + Some(EventKind::WebSocketResponse) => &mut websocket, + None => return Err(AdminError::invalid("观察绑定必须选择有效事件类型")), + }; + if target.replace(BindingScope::compile(binding)?).is_some() { + return Err(AdminError::invalid("同一观察事件不能重复绑定")); } } Ok(order.map(|order| CompiledBindings { order, - lifecycle, - usage, + completed, websocket, })) } @@ -124,7 +153,6 @@ pub(crate) fn compile_entry( plugin_id: &str, instance_id: &str, bindings: &[PluginCapabilityBinding], - permissions: &[Permission], session: Arc, callbacks: Arc, ) -> Result, AdminError> { @@ -135,10 +163,8 @@ pub(crate) fn compile_entry( instance_id: instance_id.to_owned(), session, callbacks, - lifecycle: compiled.lifecycle, - usage: compiled.usage, + completed: compiled.completed, websocket: compiled.websocket, - requests_authorized: permissions.contains(&Permission::Requests), }), ) } @@ -223,23 +249,16 @@ impl RequestObserverPlan for PluginObserverPlan { if entry_indexes.is_empty() { return; } - let include_payload = entry_indexes - .iter() - .any(|index| self.entries[*index].requests_authorized); - let payload = if include_payload { - match websocket_payload(&observation) { - Ok(payload) => payload, - Err(()) => { - tracing::warn!( - request_id = observation.request_id().as_str(), - sequence = observation.sequence(), - "插件 WebSocket 响应观察编码失败,已丢弃" - ); - return; - } + let payload = match websocket_payload(&observation) { + Ok(payload) => payload, + Err(()) => { + tracing::warn!( + request_id = observation.request_id().as_str(), + sequence = observation.sequence(), + "插件 WebSocket 响应观察编码失败,已丢弃" + ); + return; } - } else { - Vec::new() }; if payload.len() > self.maximum_websocket_payload_bytes { tracing::warn!( @@ -299,7 +318,7 @@ impl RequestObserverPlan for PluginObserverPlan { self.close_websocket_queue(observation.request_id().as_str()); if !self.entries.iter().any(|entry| { !observation.suppresses_plugin(&entry.instance_id) - && entry.subscriptions(&observation) != (false, false) + && entry.matches_completed(&observation) }) { return; } @@ -326,59 +345,17 @@ impl RequestObserverPlan for PluginObserverPlan { if observation.suppresses_plugin(&entry.instance_id) { continue; } - let (lifecycle, usage) = entry.subscriptions(&observation); - if !lifecycle && !usage { + if !entry.matches_completed(&observation) { continue; } - let request = wire_observation(&observation, lifecycle, usage); - let Ok(payload) = serde_json::to_vec(&request) else { - tracing::warn!( - plugin_id = entry.plugin_id, - instance_id = entry.instance_id, - request_id = observation.request_id().as_str(), - "插件终态观察编码失败" - ); - continue; - }; - let mut context = entry.session.context(Stage::Observation, timeout); - context.request_id = Some(observation.request_id().as_str().to_owned()); - let Ok(_scope) = entry - .callbacks - .prepare_observation(&context, observation.extension_scope().clone()) - else { - continue; - }; - match entry - .session - .call( - "policy.observe_request", - context, - serde_json::json!({}), - payload, + entry + .observe( + Event::RequestCompleted(Box::new(wire_observation(&observation))), + Vec::new(), + observation.extension_scope().clone(), + timeout, ) - .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(_) => tracing::warn!( - plugin_id = entry.plugin_id, - instance_id = entry.instance_id, - request_id = observation.request_id().as_str(), - "插件终态观察返回了无效结果" - ), - Err(error) => tracing::warn!( - plugin_id = entry.plugin_id, - instance_id = entry.instance_id, - request_id = observation.request_id().as_str(), - %error, - "插件终态观察失败" - ), - } + .await; } }); } @@ -430,8 +407,8 @@ async fn dispatch_websocket_queue( while let Some(queued) = receiver.recv().await { for index in queued.entry_indexes.iter().copied() { let entry = &entries[index]; - let include_payload = entry.requests_authorized && !queued.payload.is_empty(); - let request = ObserveWebSocketResponse { + let include_payload = !queued.payload.is_empty(); + let request = WebSocketResponse { event_id: queued.observation.event_id().to_owned(), request_id: queued.observation.request_id().as_str().to_owned(), config_revision: queued.observation.config_revision().get(), @@ -449,63 +426,16 @@ async fn dispatch_websocket_queue( event_type: include_payload .then(|| queued.observation.wire().event_type()) .flatten() - .filter(|event_type| safe_websocket_event_type(event_type)) .map(str::to_owned), }; - let mut context = entry.session.context(Stage::Observation, timeout); - context.request_id = Some(queued.observation.request_id().as_str().to_owned()); - let Ok(_scope) = entry - .callbacks - .prepare_observation(&context, queued.observation.extension_scope().clone()) - else { - continue; - }; - let result = entry - .session - .call( - "websocket.response_event", - context, - match serde_json::to_value(request) { - Ok(request) => request, - Err(_) => { - tracing::warn!( - plugin_id = entry.plugin_id, - instance_id = entry.instance_id, - request_id = queued.observation.request_id().as_str(), - "插件 WebSocket 响应观察元数据编码失败" - ); - continue; - } - }, - if include_payload { - queued.payload.to_vec() - } else { - Vec::new() - }, + entry + .observe( + Event::WebSocketResponse(Box::new(request)), + queued.payload.to_vec(), + queued.observation.extension_scope().clone(), + timeout, ) .await; - match result { - Ok(reply) - if reply.payload.is_empty() - && (reply.result.is_null() - || reply - .result - .as_object() - .is_some_and(serde_json::Map::is_empty)) => {} - Ok(_) => tracing::warn!( - plugin_id = entry.plugin_id, - instance_id = entry.instance_id, - request_id = queued.observation.request_id().as_str(), - "插件 WebSocket 响应观察返回了无效结果" - ), - Err(error) => tracing::warn!( - plugin_id = entry.plugin_id, - instance_id = entry.instance_id, - request_id = queued.observation.request_id().as_str(), - %error, - "插件 WebSocket 响应观察失败" - ), - } } } } @@ -520,18 +450,8 @@ fn websocket_payload(observation: &WebSocketResponseObservation) -> Result bool { - !event_type.is_empty() - && event_type.len() <= MAX_WEBSOCKET_EVENT_TYPE_BYTES - && !event_type.chars().any(char::is_control) -} - -fn wire_observation( - observation: &RequestObservation, - include_lifecycle: bool, - include_usage: bool, -) -> ObserveRequest { - ObserveRequest { +fn wire_observation(observation: &RequestObservation) -> RequestCompleted { + RequestCompleted { event_id: observation.event_id().to_owned(), request_id: observation.request_id().as_str().to_owned(), config_revision: observation.config_revision().get(), @@ -554,7 +474,7 @@ fn wire_observation( .provider() .map(|provider| provider.as_str().to_owned()), completed_at_ms: millis_since_epoch(observation.completed_at()), - terminal: include_lifecycle.then(|| RequestTerminal { + terminal: RequestTerminal { outcome: wire_outcome(observation.outcome()), send_state: wire_send_state(observation.send_state()), attempt_count: observation.attempt_count(), @@ -562,8 +482,8 @@ fn wire_observation( error_code: observation .error_kind() .map(|error| error.as_str().to_owned()), - }), - usage: include_usage.then(|| { + }, + usage: { let usage = observation.usage(); RequestUsage { input_tokens: usage.input_tokens, @@ -578,7 +498,7 @@ fn wire_observation( timings: Some(wire_timings(observation)), failure: wire_failure(observation), } - }), + }, } } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/http/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/http/mod.rs new file mode 100644 index 000000000..51b1322f3 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/http/mod.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use futures::stream; +use gateway_admin::model::plugins::instances::PluginFailurePolicy; +use gateway_core::{engine::middleware::MiddlewareError, middleware::http as core}; +use gateway_plugin_sdk::{ + Stage, + call::middleware::{HANDLE_METHOD, http as wire}, +}; +use http_body::Frame; +use http_body_util::{BodyExt as _, StreamBody}; + +use super::MiddlewareEntry; +use crate::callback::http_middleware::{Invocation, headers}; + +pub(super) async fn invoke( + entry: Arc, + context: core::Context, + request: core::Request, + next: core::Next, + timeout: std::time::Duration, +) -> Result { + let Some(ports) = &entry.invocation else { + return if entry.failure_policy == PluginFailurePolicy::Delegate { + next.run(request).await + } else { + Err(MiddlewareError::Fault) + }; + }; + let settings_sources = request + .extensions() + .get::() + .and_then(|settings| settings.runtime.as_ref()) + .map(|settings| settings.inspect()) + .unwrap_or(serde_json::Value::Null); + let (invocation, request) = Invocation::new(request, next, entry.instance_id.clone())?; + let mut call = ports.session.context(Stage::Http, timeout); + call.request_id = Some(context.request_id.clone()); + call.resource_stream = true; + let _binding = ports + .callbacks + .bind_invocation( + &call, + context + .plan + .clone() + .map(|plan| crate::callback::InvocationContext { + request_id: context.request_id.clone(), + call_id: context.call_id.clone(), + cancellation: context.cancellation.clone(), + plan, + }), + context.extensions.clone(), + invocation.clone(), + ) + .map_err(|_| MiddlewareError::Fault)?; + let mut stream = tokio::select! { + biased; + () = context.cancellation.cancelled() => return Err(MiddlewareError::Fault), + result = ports.session.call_stream(HANDLE_METHOD, call, serde_json::to_value(wire::Call { settings_sources, request_id: context.request_id, call_id: context.call_id, parent_call_id: context.parent_call_id, request }).map_err(|_| MiddlewareError::InvalidState)?, Vec::new()) => result.map_err(crate::callback::error::rpc_middleware)?, + }; + let response: wire::Response = + serde_json::from_value(std::mem::take(&mut stream.initial.result)) + .map_err(|_| MiddlewareError::InvalidState)?; + let payload = std::mem::take(&mut stream.initial.payload); + if response.session { + let status = invocation + .session_status() + .ok_or(MiddlewareError::InvalidState)?; + if response.status != status.as_u16() + || response.response.is_none() + || !matches!(response.body, wire::Body::Empty) + || !payload.is_empty() + { + return Err(MiddlewareError::InvalidState); + } + let mut response = invocation.complete(response, payload, None).await?; + let task = Box::pin(async move { + let _binding = _binding; + tokio::select! { + biased; + () = context.cancellation.cancelled() => Err(MiddlewareError::Fault), + result = async { + invocation.session_ready().await?; + match stream.next().await { + Ok(None) => Ok(()), + _ => Err(MiddlewareError::Fault), + } + } => result, + } + }); + response + .extensions_mut() + .insert(core::upgrade::Upgraded::new(status, task)); + return Ok(response); + } + let body = if matches!(response.body, wire::Body::Stream) { + let body = StreamBody::new(stream::try_unfold((stream, invocation.clone(), context.cancellation), |(mut stream, invocation, cancellation)| async move { + let chunk = tokio::select! { + biased; + () = cancellation.cancelled() => return Err(Box::new(MiddlewareError::Fault) as Box), + chunk = stream.next() => chunk.map_err(|error| Box::new(crate::callback::error::rpc_middleware(error)) as Box)?, + }; + let Some(chunk) = chunk else { return Ok(None); }; + let frame = match chunk.split_first() { + Some((0, data)) => Frame::data(bytes::Bytes::copy_from_slice(data)), + Some((1, data)) => Frame::trailers(headers(serde_json::from_slice(data).map_err(|_| MiddlewareError::InvalidState)?).map_err(|_| MiddlewareError::InvalidState)?), + _ => return Err(Box::new(MiddlewareError::InvalidState) as _), + }; + Ok(Some((frame, (stream, invocation, cancellation)))) + })).boxed_unsync(); + Some(body) + } else { + // 返回句柄时不拉取正文,仍消费唯一 End,释放本次 RPC 槽位。 + if stream + .next() + .await + .map_err(crate::callback::error::rpc_middleware)? + .is_some() + { + return Err(MiddlewareError::InvalidState); + } + None + }; + invocation.complete(response, payload, body).await +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/middleware.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/middleware.rs index c07b980ad..a847c77c9 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/middleware.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/middleware.rs @@ -10,6 +10,7 @@ use gateway_core::engine::middleware::{ MiddlewareBody, MiddlewareContext, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareMount, MiddlewareNext, MiddlewarePlan, MiddlewareRequest, MiddlewareResponse, }; +use gateway_core::middleware::{Middleware, Next}; use gateway_plugin_sdk::{ ErrorCode, SendState, Stage, call::middleware::{ @@ -30,89 +31,151 @@ use super::{MiddlewareEntry, PluginRequestPolicyPlan}; const MAXIMUM_MAPPED_FRAMES_PER_SOURCE: usize = 64; const MAXIMUM_MAPPED_BYTES_PER_SOURCE: usize = 8 * 1024 * 1024; +// 协议适配只提供选择条件与单层调用;顺序、上下文捕获和续体拼接共用这一个入口。 +type Invoke = fn( + Arc, + Context, + Input, + Next, + Duration, +) -> BoxFuture<'static, Result>; + +impl PluginRequestPolicyPlan { + fn chain( + &self, + mount: MiddlewareMount, + context: Context, + select: impl Fn(&MiddlewareEntry, &Context) -> bool, + invoke: Invoke, + next: Next, + ) -> Next + where + Context: Clone + Send + 'static, + Input: Send + 'static, + Output: Send + 'static, + Error: Send + 'static, + { + let Some(entries) = self.middleware.get(&mount) else { + return next; + }; + let layers = entries + .iter() + .filter(|entry| select(entry, &context)) + .map(|entry| -> Middleware { + let entry = Arc::clone(entry); + let context = context.clone(); + let timeout = self.middleware_timeout; + Box::new(move |input, next| invoke(entry, context, input, next, timeout)) + }) + .collect(); + gateway_core::middleware::compose(layers, move |input| next.run(input)) + } +} + impl MiddlewarePlan for PluginRequestPolicyPlan { + fn has_service(&self) -> bool { + self.middleware.contains_key(&MiddlewareMount::Service) + } + fn handle_service( + &self, + context: gateway_core::middleware::service::Context, + input: gateway_core::middleware::service::Value, + next: gateway_core::middleware::service::Next, + ) -> BoxFuture< + 'static, + Result, + > { + self.chain( + MiddlewareMount::Service, + context, + |entry, context| !context.extensions.contains(&entry.instance_id), + |entry, context, input, next, timeout| { + Box::pin(super::service::invoke(entry, context, input, next, timeout)) + }, + next, + ) + .run(input) + } + + fn has_websocket(&self) -> bool { + self.middleware.contains_key(&MiddlewareMount::WebSocket) + } + fn handle_websocket( + &self, + context: gateway_core::middleware::websocket::Context, + message: gateway_core::middleware::websocket::Message, + next: gateway_core::middleware::websocket::Next, + ) -> BoxFuture< + 'static, + Result, MiddlewareError>, + > { + self.chain( + MiddlewareMount::WebSocket, + context, + |_entry, _context| true, + |entry, context, message, next, timeout| { + Box::pin(super::websocket::invoke( + entry, context, message, next, timeout, + )) + }, + next, + ) + .run(message) + } + fn has_http(&self) -> bool { + self.middleware.contains_key(&MiddlewareMount::Http) + } + + fn handle_http( + &self, + context: gateway_core::middleware::http::Context, + request: gateway_core::middleware::http::Request, + next: gateway_core::middleware::http::Next, + ) -> BoxFuture<'static, Result> { + self.chain( + MiddlewareMount::Http, + context, + |entry, context| !context.extensions.contains(&entry.instance_id), + |entry, context, request, next, timeout| { + Box::pin(super::http::invoke(entry, context, request, next, timeout)) + }, + next, + ) + .run(request) + } + fn handle( &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { - let applicable = self - .middleware - .iter() - .enumerate() - .filter_map(|(index, entry)| { - (entry.mount == context.mount() - && !context.extension_scope().contains(&entry.instance_id) + self.chain( + context.mount(), + context, + |entry, context| { + !context.extension_scope().contains(&entry.instance_id) && entry.scope.matches_processing( context.client_key_id(), context.account_group_ids(), context.provider(), context.model(), - )) - .then_some(index) - }) - .collect::>(); - Box::new(MiddlewareChainNext { - entries: Arc::clone(&self.middleware), - applicable: applicable.into(), - index: 0, - context, - terminal: Some(next), - timeout: self.middleware_timeout, - }) + ) + }, + |entry, context, request, next, timeout| { + Box::pin(invoke_middleware(entry, context, request, next, timeout)) + }, + next, + ) .run(request) } } -struct MiddlewareChainNext { - entries: Arc<[MiddlewareEntry]>, - applicable: Arc<[usize]>, - index: usize, - context: MiddlewareContext, - terminal: Option>, - timeout: Duration, -} - -impl MiddlewareNext for MiddlewareChainNext { - fn run( - mut self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - let Some(entry_index) = self.applicable.get(self.index).copied() else { - return self - .terminal - .take() - .ok_or(MiddlewareError::InvalidState)? - .run(request) - .await; - }; - let next = Box::new(Self { - entries: Arc::clone(&self.entries), - applicable: Arc::clone(&self.applicable), - index: self.index + 1, - context: self.context.clone(), - terminal: self.terminal.take(), - timeout: self.timeout, - }); - invoke_middleware( - &self.entries[entry_index], - self.context, - request, - next, - self.timeout, - ) - .await - }) - } -} - async fn invoke_middleware( - entry: &MiddlewareEntry, + entry: Arc, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, maximum_timeout: Duration, ) -> Result { let Some(invocation_ports) = &entry.invocation else { @@ -132,19 +195,16 @@ async fn invoke_middleware( if remaining.is_zero() || context.cancellation().is_cancelled() { return Err(MiddlewareError::Fault); } - let invocation = MiddlewareInvocation::new( - &context, - request, - next, - entry.requests_authorized, - entry.capability_version, - ); - let (protocol, headers, body_visible, payload) = match invocation.request_projection() { + let invocation = MiddlewareInvocation::new(&context, request, next, entry.instance_id.clone()); + let (protocol, headers, payload) = match invocation.request_parts() { Ok(projection) => projection, - Err(_) => return recover_invalid(entry, &invocation).await, + Err(_) => return recover_invalid(&entry, &invocation).await, }; let mut call_context = invocation_ports.session.context( match entry.mount { + MiddlewareMount::Http | MiddlewareMount::WebSocket | MiddlewareMount::Service => { + return Err(MiddlewareError::InvalidState); + } MiddlewareMount::Request => Stage::Request, MiddlewareMount::Attempt => Stage::Attempt, }, @@ -157,22 +217,41 @@ async fn invoke_middleware( call_context.account_id = context .account_id() .map(|account| account.as_str().to_owned()); - let _network_scope = context - .execution_effects() - .map(|effects| { - invocation_ports - .callbacks - .prepare_data_plane(&call_context, effects) - }) - .transpose() + let scope = invocation_ports + .callbacks + .prepare_data_plane( + &call_context, + context.execution_effects(), + context.extension_scope().clone(), + context.plan(), + context.cancellation().clone(), + ) .map_err(|_| MiddlewareError::Fault)?; let _binding = invocation_ports.callbacks.bind_middleware( call_context.resource_scope_id.clone(), - Arc::clone(&invocation), + invocation.clone(), + scope, )?; let head = MiddlewareRequestHead { + settings_sources: crate::callback::MiddlewareCallback::request_settings( + invocation.as_ref(), + ) + .map(|settings| settings.inspect()) + .unwrap_or(serde_json::Value::Null), + settings: invocation + .settings() + .map_err(|_| MiddlewareError::InvalidState)?, + client_key_id: context.client_key_id().as_str().to_owned(), + account_group_ids: context + .account_group_ids() + .iter() + .map(|id| id.as_str().to_owned()) + .collect(), request_id: context.request_id().as_str().to_owned(), mount: match context.mount() { + MiddlewareMount::Http | MiddlewareMount::WebSocket | MiddlewareMount::Service => { + return Err(MiddlewareError::InvalidState); + } MiddlewareMount::Request => WireMiddlewareMount::Request, MiddlewareMount::Attempt => WireMiddlewareMount::Attempt, }, @@ -192,7 +271,6 @@ async fn invoke_middleware( .account_id() .map(|account| account.as_str().to_owned()), headers, - body_visible, }; let stream = tokio::select! { biased; @@ -204,19 +282,19 @@ async fn invoke_middleware( payload, ) => match result { Ok(stream) => stream, - Err(error) => return recover_rpc(entry, &invocation, error).await, + Err(error) => return recover_rpc(&entry, &invocation, error).await, } }; if !stream.initial.payload.is_empty() { drop(stream); - return recover_invalid(entry, &invocation).await; + return recover_invalid(&entry, &invocation).await; } let response: MiddlewareResponseHead = match serde_json::from_value(stream.initial.result.clone()) { Ok(response) => response, Err(_) => { drop(stream); - return recover_invalid(entry, &invocation).await; + return recover_invalid(&entry, &invocation).await; } }; let completion = match invocation.resolve_response(response).await { @@ -292,11 +370,7 @@ async fn recover_rpc( ); return next.run(request).await; } - if rejected { - Err(MiddlewareError::Rejected) - } else { - Err(MiddlewareError::Fault) - } + Err(crate::callback::error::rpc_middleware(error)) } async fn recover_invalid( @@ -324,9 +398,9 @@ async fn drain_empty_stream( match stream.next().await { Ok(None) => invocation.take_downstream_error().map_or(Ok(()), Err), Ok(Some(_)) => Err(MiddlewareError::InvalidState), - Err(_) => invocation + Err(error) => invocation .take_downstream_error() - .map_or(Err(MiddlewareError::Fault), Err), + .map_or_else(|| Err(crate::callback::error::rpc_middleware(error)), Err), } } @@ -369,11 +443,10 @@ impl MiddlewareBody for PluginMiddlewareBody { }; let chunk = match stream.next().await { Ok(chunk) => chunk, - Err(_) => { + Err(error) => { self.stream.take(); - return Err( - take_error(&self.downstream_error).unwrap_or(MiddlewareError::Fault) - ); + return Err(take_error(&self.downstream_error) + .unwrap_or_else(|| crate::callback::error::rpc_middleware(error))); } }; let Some(chunk) = chunk else { @@ -397,11 +470,6 @@ impl MiddlewareBody for PluginMiddlewareBody { .map_err(|_| MiddlewareError::InvalidState)?; let source_id = wire_frame.source_id(); let disposition = wire_frame.disposition(); - if !matches!(disposition, MiddlewareBodyDisposition::Drop) - && !valid_plugin_frame(self.framing, &wire_frame.payload) - { - return Err(MiddlewareError::InvalidState); - } let frame = match (&self.downstream, disposition) { (None, MiddlewareBodyDisposition::Standalone) => Some( MiddlewareFrame::new( @@ -508,6 +576,14 @@ impl MiddlewareBody for PluginMiddlewareBody { continue; } let mut frame = frame.ok_or(MiddlewareError::InvalidState)?; + // 只有宿主来源事件本就没有 wire 时,空 payload 才是事实帧。 + let facts_only = frame.bytes().is_empty() + && frame + .event() + .is_some_and(|event| event.wire_event().is_none()); + if !facts_only && !valid_plugin_frame(self.framing, frame.bytes()) { + return Err(MiddlewareError::InvalidState); + } if self.pending_transformed { frame = frame.with_transformed(true); self.pending_transformed = false; @@ -583,7 +659,8 @@ fn validate_terminal_end( match terminal { Ok(None) => Ok(()), Ok(Some(_)) => Err(take_error(downstream_error).unwrap_or(MiddlewareError::InvalidState)), - Err(_) => Err(take_error(downstream_error).unwrap_or(MiddlewareError::Fault)), + Err(error) => Err(take_error(downstream_error) + .unwrap_or_else(|| crate::callback::error::rpc_middleware(error))), } } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/mod.rs index be595a3c1..4b4f7c134 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/mod.rs @@ -1,15 +1,18 @@ +mod http; mod middleware; mod retry; mod route_schedule; +mod service; +mod websocket; -use std::{fmt, sync::Arc, time::Duration}; +use std::{collections::BTreeMap, fmt, sync::Arc, time::Duration}; use gateway_admin::model::{ AdminError, plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy, PluginInstance}, }; use gateway_core::engine::middleware::MiddlewareMount; -use gateway_plugin_sdk::{Capability, Manifest, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Manifest, Stage}; use crate::{RpcSession, adapter::scope::BindingScope, callback::PluginCallbacks}; @@ -43,7 +46,6 @@ pub(crate) struct ModelRouterEntry { invocation: Option, scope: BindingScope, failure_policy: PluginFailurePolicy, - requests_authorized: bool, } pub(crate) struct AccountSchedulerEntry { @@ -52,11 +54,9 @@ pub(crate) struct AccountSchedulerEntry { invocation: Option, scope: BindingScope, failure_policy: PluginFailurePolicy, - requests_authorized: bool, } pub(crate) struct MiddlewareEntry { - capability_version: u32, order: i32, plugin_id: String, instance_id: String, @@ -64,7 +64,6 @@ pub(crate) struct MiddlewareEntry { invocation: Option, scope: BindingScope, failure_policy: PluginFailurePolicy, - requests_authorized: bool, } pub(crate) fn validate_bindings( @@ -120,16 +119,32 @@ pub(crate) fn validate_bindings( } } Capability::Middleware => { - if !matches!(stage, Stage::Request | Stage::Attempt) - || !middleware_stages.insert(stage) + if !matches!( + stage, + Stage::Http + | Stage::WebSocket + | Stage::Service + | Stage::Request + | Stage::Attempt + ) || !middleware_stages.insert(stage) { return Err(AdminError::invalid( - "同一插件实例的中间件只能在 request/attempt 各绑定一次", + "同一插件实例的中间件只能在 http/websocket/service/request/attempt 各绑定一次", )); } } _ => unreachable!("capability was filtered above"), } + if matches!(stage, Stage::Http | Stage::WebSocket | Stage::Service) + && (!binding.client_key_ids.is_empty() + || !binding.account_group_ids.is_empty() + || !binding.provider_ids.is_empty() + || !binding.models.is_empty()) + { + return Err(AdminError::invalid( + "入口和公开服务不保证具有模型执行身份,不能绑定 Key、分组、Provider 或模型条件", + )); + } let scope = BindingScope::compile(binding)?; if (capability == Capability::ModelRouter || (capability == Capability::Middleware && stage == Stage::Request)) @@ -147,7 +162,6 @@ pub(crate) fn compile_entries( manifest: &Manifest, instance_id: &str, bindings: &[PluginCapabilityBinding], - permissions: &[Permission], session: Arc, callbacks: Arc, ) -> Result, AdminError> { @@ -176,9 +190,7 @@ pub(crate) fn compile_entries( instance_id, binding, capability, - manifest.contributes[&capability].version, Some(invocation.clone()), - permissions.contains(&Permission::Requests), ) }) }) @@ -197,7 +209,7 @@ pub(crate) fn unavailable_entries( "routing" => Capability::ModelRouter, "scheduling" => Capability::Scheduler, "retry" => Capability::RetryPolicy, - "request" | "attempt" => Capability::Middleware, + "http" | "websocket" | "service" | "request" | "attempt" => Capability::Middleware, _ => return None, }; Some(compile_entry( @@ -208,9 +220,7 @@ pub(crate) fn unavailable_entries( &instance.id, binding, capability, - 1, None, - false, )) }) .collect() @@ -221,9 +231,7 @@ fn compile_entry( instance_id: &str, binding: &PluginCapabilityBinding, capability: Capability, - capability_version: u32, invocation: Option, - requests_authorized: bool, ) -> Result { let scope = BindingScope::compile(binding)?; Ok(match capability { @@ -241,7 +249,6 @@ fn compile_entry( invocation, scope, failure_policy: binding.failure_policy.clone(), - requests_authorized, }), Capability::Scheduler => PolicyEntry::Scheduler(AccountSchedulerEntry { plugin_id: plugin_id.to_owned(), @@ -249,14 +256,15 @@ fn compile_entry( invocation, scope, failure_policy: binding.failure_policy.clone(), - requests_authorized, }), Capability::Middleware => PolicyEntry::Middleware(MiddlewareEntry { - capability_version, order: binding.order, plugin_id: plugin_id.to_owned(), instance_id: instance_id.to_owned(), mount: match binding.stage.as_str() { + "http" => MiddlewareMount::Http, + "service" => MiddlewareMount::Service, + "websocket" => MiddlewareMount::WebSocket, "request" => MiddlewareMount::Request, "attempt" => MiddlewareMount::Attempt, _ => return Err(AdminError::invalid("插件中间件阶段无效")), @@ -264,7 +272,6 @@ fn compile_entry( invocation, scope, failure_policy: binding.failure_policy.clone(), - requests_authorized, }), _ => return Err(AdminError::invalid("插件请求策略能力无效")), }) @@ -274,7 +281,7 @@ pub(crate) struct PluginRequestPolicyPlan { routers: Arc<[ModelRouterEntry]>, schedulers: Arc<[AccountSchedulerEntry]>, retries: Arc<[RetryEntry]>, - middleware: Arc<[MiddlewareEntry]>, + middleware: BTreeMap]>>, policy_timeout: Duration, middleware_timeout: Duration, } @@ -286,7 +293,14 @@ impl fmt::Debug for PluginRequestPolicyPlan { .field("router_count", &self.routers.len()) .field("scheduler_count", &self.schedulers.len()) .field("retry_count", &self.retries.len()) - .field("middleware_count", &self.middleware.len()) + .field( + "middleware_count", + &self + .middleware + .values() + .map(|entries| entries.len()) + .sum::(), + ) .finish_non_exhaustive() } } @@ -338,24 +352,28 @@ impl PluginRequestPolicyPlan { } } middleware.sort_by(|left, right| { - ( - mount_order(left.mount), - left.order, - &left.plugin_id, - &left.instance_id, - ) - .cmp(&( - mount_order(right.mount), - right.order, - &right.plugin_id, - &right.instance_id, - )) + (left.order, &left.plugin_id, &left.instance_id).cmp(&( + right.order, + &right.plugin_id, + &right.instance_id, + )) }); + // 发布时固定各挂载位置的有序候选,消息处理不再扫描其他边界的绑定。 + let mut by_mount = BTreeMap::<_, Vec<_>>::new(); + for entry in middleware { + by_mount + .entry(entry.mount) + .or_default() + .push(Arc::new(entry)); + } Ok(Some(Arc::new(Self { routers: routers.into(), schedulers: schedulers.into(), retries: retries.into(), - middleware: middleware.into(), + middleware: by_mount + .into_iter() + .map(|(mount, entries)| (mount, entries.into_boxed_slice())) + .collect(), policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT), middleware_timeout: maximum_call_timeout, }))) @@ -371,10 +389,3 @@ impl PluginRequestPolicyPlan { !self.middleware.is_empty() } } - -const fn mount_order(mount: MiddlewareMount) -> u8 { - match mount { - MiddlewareMount::Request => 0, - MiddlewareMount::Attempt => 1, - } -} 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 0c04ed171..57c7d8b7f 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 @@ -110,16 +110,8 @@ async fn route_with_entry( ) -> Result { let invocation = entry.invocation.as_ref().ok_or(())?; let projection = operation_projection(input.operation())?; - let payload = if entry.requests_authorized { - projection.body.to_bytes()? - } else { - Vec::new() - }; - let headers = if entry.requests_authorized { - project_request_headers(input.operation(), projection.context)? - } else { - Vec::new() - }; + let payload = projection.body.to_bytes()?; + let headers = project_request_headers(input.operation(), projection.context)?; let request = ModelRouteRequest { request_id: input.request_id().as_str().to_owned(), operation: input.operation().kind().as_str().to_owned(), @@ -136,7 +128,13 @@ async fn route_with_entry( context.request_id = Some(input.request_id().as_str().to_owned()); let _network_scope = invocation .callbacks - .prepare_data_plane(&context, input.execution_effects()) + .prepare_data_plane( + &context, + Some(input.execution_effects()), + input.extension_scope().clone(), + None, + gateway_core::lifecycle::CancellationToken::new(), + ) .map_err(|_| ())?; let reply = invocation .session @@ -182,23 +180,13 @@ async fn schedule_with_entry( timeout: Duration, ) -> Result { let invocation = entry.invocation.as_ref().ok_or(())?; - let highest_weight = input - .candidates() - .iter() - .map(gateway_core::engine::policy::AccountScheduleCandidate::weight) - .max() - .ok_or(())?; - let visible = input - .candidates() - .iter() - .filter(|candidate| entry.requests_authorized || candidate.weight() == highest_weight) - .collect::>(); + let candidates = input.candidates(); let request = WireAccountScheduleRequest { request_id: input.request_id().as_str().to_owned(), attempt_index: input.attempt_index().get(), provider: input.provider().as_str().to_owned(), model: input.model().map(str::to_owned), - candidates: visible + candidates: candidates .iter() .map(|candidate| WireAccountScheduleCandidate { account_id: candidate.account_id().as_str().to_owned(), @@ -222,7 +210,13 @@ async fn schedule_with_entry( )); let _network_scope = invocation .callbacks - .prepare_data_plane(&context, input.execution_effects()) + .prepare_data_plane( + &context, + Some(input.execution_effects()), + input.extension_scope().clone(), + None, + gateway_core::lifecycle::CancellationToken::new(), + ) .map_err(|_| ())?; let reply = invocation .session @@ -244,7 +238,7 @@ async fn schedule_with_entry( WireAccountScheduleDecision::Reject => Ok(AccountScheduleDecision::Reject), WireAccountScheduleDecision::Pick { account_id } => { let account_id = ProviderAccountId::new(account_id).map_err(|_| ())?; - if !visible + if !candidates .iter() .any(|candidate| candidate.account_id() == &account_id) { @@ -350,9 +344,6 @@ fn project_provider_http_headers(headers: &[ProviderHttpHeader]) -> Result MAX_HEADER_TOTAL_BYTES { return Err(()); } - if sensitive_or_identity_header(&name) { - continue; - } projected.push(PolicyHeader { name, value_base64 }); } Ok(projected) @@ -389,9 +380,6 @@ fn project_headers( if total_bytes > MAX_HEADER_TOTAL_BYTES { return Err(()); } - if sensitive_or_identity_header(&name) { - continue; - } projected.push(PolicyHeader { name, value_base64: value_base64.to_owned(), @@ -406,7 +394,6 @@ fn normalized_header_name(name: &str) -> Result { || name.len() > MAX_HEADER_NAME_BYTES || !name .bytes() - // HTTP token 允许下划线等字符;合法会话别名仍由后续可见性规则隐藏。 .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) { return Err(()); @@ -414,26 +401,6 @@ fn normalized_header_name(name: &str) -> Result { Ok(name) } -fn sensitive_or_identity_header(name: &str) -> bool { - name.contains("auth") - || name.contains("credential") - || name.contains("secret") - || name.contains("token") - || name.contains("cookie") - || name.contains("session") - || name.contains("conversation") - || name.contains("thread") - || name.contains("account") - || name.contains("organization") - || name.contains("project") - || name.contains("tenant") - || name.contains("principal") - || name.contains("identity") - || name.contains("user-id") - || name.ends_with("-key") - || name.ends_with("_key") -} - fn system_time_millis(value: Option) -> Option { value? .duration_since(UNIX_EPOCH) diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/service/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/service/mod.rs new file mode 100644 index 000000000..d031b4a97 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/service/mod.rs @@ -0,0 +1,70 @@ +//! 服务组合与 HTTP 使用相同续体;不在这里解释操作参数或执行管理业务。 + +use std::sync::Arc; + +use super::MiddlewareEntry; +use crate::callback::services::Invocation; +use gateway_admin::model::plugins::instances::PluginFailurePolicy; +use gateway_core::middleware::service as core; +use gateway_plugin_sdk::{ + Stage, + call::{middleware::HANDLE_METHOD, services as wire}, +}; + +pub(super) async fn invoke( + entry: Arc, + context: core::Context, + input: core::Value, + next: core::Next, + timeout: std::time::Duration, +) -> Result { + let Some(ports) = &entry.invocation else { + return if entry.failure_policy == PluginFailurePolicy::Delegate { + next.run(input).await + } else { + Err(core::Error::unavailable("服务中间件不可用")) + }; + }; + let invocation = Invocation::new(next); + let mut call = ports.session.context(Stage::Service, timeout); + call.request_id = Some(context.request_id.clone()); + let _binding = ports + .callbacks + .bind_invocation( + &call, + Some(crate::callback::InvocationContext { + request_id: context.request_id.clone(), + call_id: context.call_id.clone(), + cancellation: context.cancellation.clone(), + plan: context.plan.clone(), + }), + context.extensions.clone(), + invocation, + ) + .map_err(|_| core::Error::unavailable("服务调用上下文不可用"))?; + let request = wire::Call { + operation: context.operation.into(), + input, + request_id: context.request_id, + call_id: context.call_id, + parent_call_id: context.parent_call_id, + }; + let reply = tokio::select! { + biased; + () = context.cancellation.cancelled() => return Err(core::Error::unavailable("服务调用已取消")), + reply = ports.session.call(HANDLE_METHOD, call, serde_json::to_value(request).map_err(|_| core::Error::invalid("服务输入编码失败"))?, Vec::new()) => reply.map_err(|error| { + let fault = crate::callback::error::rpc(&error); + core::Error {kind:"unavailable".into(),message:fault.message.clone(),details:Some(serde_json::json!(fault))} + })?, + }; + if !reply.payload.is_empty() { + return Err(core::Error::invalid("服务返回了意外的二进制正文")); + } + let response: wire::Response = serde_json::from_value(reply.result) + .map_err(|_| core::Error::invalid("服务结果类型无效"))?; + response.map_err(|error| core::Error { + kind: error.kind, + message: error.message, + details: error.details, + }) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/websocket/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/websocket/mod.rs new file mode 100644 index 000000000..c4ee8e901 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/websocket/mod.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use super::MiddlewareEntry; +use crate::callback::websocket_middleware::Invocation; +use gateway_admin::model::plugins::instances::PluginFailurePolicy; +use gateway_core::{engine::middleware::MiddlewareError, middleware::websocket as core}; +use gateway_plugin_sdk::{ + Stage, + call::middleware::{HANDLE_METHOD, MiddlewareHeader, websocket as wire}, +}; + +pub(super) async fn invoke( + entry: Arc, + context: core::Context, + message: core::Message, + next: core::Next, + timeout: std::time::Duration, +) -> Result, MiddlewareError> { + let Some(ports) = &entry.invocation else { + return if entry.failure_policy == PluginFailurePolicy::Delegate { + next.run(message).await + } else { + Err(MiddlewareError::Fault) + }; + }; + let invocation = Invocation::new(next, context.sender.clone()); + let input = wire::Call { + connection_id: context.connection_id.clone(), + direction: match context.direction { + core::Direction::Incoming => wire::Direction::Incoming, + core::Direction::Outgoing => wire::Direction::Outgoing, + }, + headers: context + .headers + .iter() + .map(|header| MiddlewareHeader { + name: header.name().to_owned(), + value: header.value().to_vec(), + }) + .collect(), + message: invocation + .encode(message) + .map_err(|_| MiddlewareError::Fault)?, + }; + let mut call = ports.session.context(Stage::WebSocket, timeout); + call.request_id = Some(context.connection_id.clone()); + let _binding = ports + .callbacks + .bind_invocation( + &call, + context.plan.map(|plan| crate::callback::InvocationContext { + request_id: context.connection_id.clone(), + call_id: call.resource_scope_id.clone(), + cancellation: context.cancellation.clone(), + plan, + }), + Default::default(), + invocation.clone(), + ) + .map_err(|_| MiddlewareError::Fault)?; + let reply = tokio::select! { + biased; + () = context.cancellation.cancelled() => return Err(MiddlewareError::Fault), + reply = ports.session.call(HANDLE_METHOD, call, serde_json::to_value(input).map_err(|_| MiddlewareError::InvalidState)?, Vec::new()) => reply.map_err(crate::callback::error::rpc_middleware)?, + }; + let output: Option = + serde_json::from_value(reply.result).map_err(|_| MiddlewareError::InvalidState)?; + match output { + Some(message) => invocation + .decode(message, reply.payload) + .map(Some) + .map_err(|_| MiddlewareError::InvalidState), + None if reply.payload.is_empty() => Ok(None), + None => Err(MiddlewareError::InvalidState), + } +} 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 3f5c982ed..04f3c70ad 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 @@ -23,7 +23,7 @@ use super::{ use crate::{ RpcError, RpcStream, callback::{ - NetworkScope, + CallbackScope, upstream::{ConnectionOwner, ManagedUpstream}, }, }; @@ -50,7 +50,7 @@ struct ActiveCall { context: CallContext, stream: RpcStream, managed: Arc, - _scope: Arc, + _scope: Arc, } pub(super) fn execute( 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 83cbf2744..289e9df96 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 @@ -5,7 +5,7 @@ use gateway_admin::model::{ plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy, PluginInstance}, }; use gateway_plugin_sdk::{ - Capability, Manifest, Permission, Stage, + Capability, Manifest, Stage, call::upstream_adapter::{REGISTER_METHOD, UpstreamAdapterRegistration}, }; @@ -51,17 +51,6 @@ pub(crate) async fn prepare( return Ok(Vec::new()); }; validate_bindings(manifest, &instance.bindings)?; - for permission in [Permission::Requests, Permission::UpstreamConnections] { - if !instance - .grants - .iter() - .any(|grant| grant.permission == permission.as_str()) - { - return Err(AdminError::invalid( - "上游适配器需要 requests 和 upstream_connections 授权", - )); - } - } let reply = session .call( REGISTER_METHOD, 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 b0c3d0c31..a79acb7a7 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 @@ -51,7 +51,11 @@ impl UpstreamTarget { path: &str, query: &[(String, String)], ) -> Result<(Url, UpstreamPathPurpose), PluginFault> { - let purpose = *self.paths.get(path).ok_or_else(invalid)?; + let purpose = self + .paths + .get(path) + .copied() + .unwrap_or(UpstreamPathPurpose::Inference); if query.len() > 64 || query .iter() @@ -60,7 +64,7 @@ impl UpstreamTarget { return Err(invalid()); } let mut target = self.base.join(path).map_err(|_| invalid())?; - if target.origin() != self.base.origin() || !target.path().starts_with(self.base.path()) { + if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() { return Err(invalid()); } if !query.is_empty() { @@ -85,24 +89,6 @@ fn valid_path(path: &str) -> bool { .all(|segment| !matches!(segment, "" | "." | "..")) } -pub(crate) fn protected_header(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - matches!( - name.as_str(), - "authorization" - | "proxy-authorization" - | "cookie" - | "host" - | "connection" - | "upgrade" - | "transfer-encoding" - | "content-length" - | "proxy-connection" - | "te" - | "trailer" - ) || name.starts_with("sec-websocket-") -} - fn invalid() -> PluginFault { - PluginFault::new(ErrorCode::InvalidInput, "upstream target is not authorized") + PluginFault::new(ErrorCode::InvalidInput, "upstream target is invalid") } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs b/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs index 7226affb1..119c9ea6b 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs @@ -6,7 +6,6 @@ use chrono::{DateTime, Utc}; use gateway_admin::{ model::{ AdminError, PageSize, Revision, - plugins::instances::PluginPermissionGrant, provider_credentials::{ PluginAccountListQuery, PreparedCredentialCreate, PreparedCredentialRotationFacts, PreparedPluginAccountSave, ProviderDocument, @@ -27,7 +26,7 @@ use gateway_plugin_sdk::{ }; use super::{ - NetworkScope, + CallbackScope, admin::{encode, map_admin_error, mutation_context}, invalid, }; @@ -60,21 +59,17 @@ impl PluginAccountPortSlot { pub(super) struct PluginAccounts { slot: Arc, - authorized: bool, } impl PluginAccounts { - pub(super) fn new(slot: Arc, grants: &[PluginPermissionGrant]) -> Self { - Self { - slot, - authorized: grants.iter().any(|grant| grant.permission == "accounts"), - } + pub(super) fn new(slot: Arc) -> Self { + Self { slot } } pub(super) async fn call( &self, context: &CallContext, - scope: &NetworkScope, + scope: &CallbackScope, method: &str, params: serde_json::Value, payload: &[u8], @@ -82,7 +77,6 @@ impl PluginAccounts { if params != serde_json::json!({}) { return Err(invalid()); } - self.authorize()?; let access = self.slot.upgrade().map_err(map_admin_error)?; match method { "host.auth.list" => { @@ -151,10 +145,9 @@ impl PluginAccounts { pub(super) async fn save( &self, context: &CallContext, - scope: &NetworkScope, + scope: &CallbackScope, request: AuthSaveRequest, ) -> Result { - self.authorize()?; let access = self.slot.upgrade().map_err(map_admin_error)?; let prepared = match request { AuthSaveRequest::Create { provider_id, facts } => { @@ -198,13 +191,6 @@ impl PluginAccounts { credential_revision: result.credential_revision.get(), }) } - - fn authorize(&self) -> Result<(), PluginFault> { - if !self.authorized { - return Err(denied()); - } - Ok(()) - } } fn prepare_create( diff --git a/backend/crates/gateway-plugin/runtime/src/callback/admin.rs b/backend/crates/gateway-plugin/runtime/src/callback/admin.rs index fdc25909f..75c245a25 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/admin.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/admin.rs @@ -34,5 +34,5 @@ pub(super) fn map_admin_error(error: AdminError) -> PluginFault { AdminErrorKind::BadGateway => ErrorCode::Upstream, AdminErrorKind::Unavailable | AdminErrorKind::Internal => ErrorCode::Fault, }; - PluginFault::new(code, "admin callback failed") + PluginFault::new(code, error.message()) } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/affinity.rs b/backend/crates/gateway-plugin/runtime/src/callback/affinity.rs index 1992ab9f1..88eef65c6 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/affinity.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/affinity.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, OnceLock, Weak}; -use gateway_admin::model::{AdminError, plugins::instances::PluginPermissionGrant}; +use gateway_admin::model::AdminError; use gateway_core::{ engine::{ModelRequestId, nested::AffinityLookupPort}, identity::ProviderKind, @@ -40,15 +40,11 @@ impl PluginAffinityPortSlot { pub(super) struct PluginAffinity { slot: Arc, - authorized: bool, } impl PluginAffinity { - pub(super) fn new(slot: Arc, grants: &[PluginPermissionGrant]) -> Self { - Self { - slot, - authorized: grants.iter().any(|grant| grant.permission == "requests"), - } + pub(super) fn new(slot: Arc) -> Self { + Self { slot } } pub(super) async fn call( @@ -67,9 +63,6 @@ impl PluginAffinity { .as_ref() .ok_or_else(denied) .and_then(|request_id| ModelRequestId::new(request_id.clone()).map_err(|_| denied()))?; - if !self.authorized { - return Err(denied()); - } let provider = ProviderKind::new(request.provider).map_err(|_| invalid())?; let result = self .slot @@ -80,7 +73,7 @@ impl PluginAffinity { key: ProviderSessionAffinityKey::try_new(request.key).map_err(|_| invalid())?, }) .await - .map_err(super::model::gateway_fault)?; + .map_err(super::error::gateway)?; Ok(RpcReply { result: serde_json::to_value(AffinityLookupResult { account_id: result.map(|result| result.account().as_str().to_owned()), diff --git a/backend/crates/gateway-plugin/runtime/src/callback/data.rs b/backend/crates/gateway-plugin/runtime/src/callback/data.rs index 813ff3916..78783eb75 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/data.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/data.rs @@ -1,11 +1,8 @@ use std::sync::Arc; -use gateway_admin::model::{ - PageSize, plugins::instances::PluginPermissionGrant, - provider_credentials::PluginAccountListQuery, -}; +use gateway_admin::model::{PageSize, provider_credentials::PluginAccountListQuery}; use gateway_core::{account::ProviderAccountId, policy::ClientApiKeyId, routing::ProviderKind}; -use gateway_plugin_sdk::{CallContext, PluginFault, Stage, call::data}; +use gateway_plugin_sdk::{PluginFault, call::data}; use super::{ accounts::PluginAccountPortSlot, admin::map_admin_error, denied, invalid, @@ -16,48 +13,22 @@ use crate::RpcReply; pub(super) struct PluginData { accounts: Arc, keys: Arc, - data_authorized: bool, - quota_authorized: bool, } impl PluginData { pub(super) fn new( accounts: Arc, keys: Arc, - grants: &[PluginPermissionGrant], ) -> Self { - Self { - accounts, - keys, - data_authorized: grants.iter().any(|grant| grant.permission == "data"), - quota_authorized: grants - .iter() - .any(|grant| grant.permission == "quota_observations"), - } + Self { accounts, keys } } pub(super) async fn call( &self, - context: &CallContext, method: &str, params: serde_json::Value, payload: &[u8], ) -> Result { - // 基础事实读取不隐含上游访问权;管理范围也不继承到客户端请求链。 - let authorized = match method { - data::ACCOUNTS_LIST | data::KEYS_GET => self.data_authorized, - data::QUOTA_GET => self.data_authorized || self.quota_authorized, - data::QUOTA_REFRESH => self.quota_authorized, - _ => false, - }; - if !authorized - || !matches!( - context.stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) - { - return Err(denied()); - } if params != serde_json::json!({}) { return Err(invalid()); } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/error.rs b/backend/crates/gateway-plugin/runtime/src/callback/error.rs new file mode 100644 index 000000000..cacb508fd --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/error.rs @@ -0,0 +1,150 @@ +//! 插件错误保留业务分类与原始详情;诊断日志仍由各 owner 单独生成。 + +use gateway_core::{ + engine::{EngineError, execution::gateway_error_from_engine, middleware::MiddlewareError}, + error::{ + ContinuationFailure, ContinuationRecoveryDisposition, GatewayError, GatewayErrorKind, + PreDeliveryRetry, ProviderError, + }, + upstream::UpstreamSendState, +}; +use gateway_plugin_sdk::{ErrorCode, PluginFault, SendState}; +use serde_json::json; + +pub(super) fn gateway(error: GatewayError) -> PluginFault { + let code = match error.kind() { + GatewayErrorKind::InvalidRequest | GatewayErrorKind::MessageTooBig => { + ErrorCode::InvalidInput + } + GatewayErrorKind::Unsupported | GatewayErrorKind::ModelNotFound => ErrorCode::Unsupported, + GatewayErrorKind::Unauthorized => ErrorCode::PermissionDenied, + GatewayErrorKind::PolicyDenied => ErrorCode::Rejected, + GatewayErrorKind::AccountCapacityUnavailable + | GatewayErrorKind::ConcurrencyQueueFull + | GatewayErrorKind::ConcurrencyQueueTimeout + | GatewayErrorKind::RateLimited + | GatewayErrorKind::NoAvailableProvider => ErrorCode::Capacity, + GatewayErrorKind::Timeout => ErrorCode::Timeout, + GatewayErrorKind::Cancelled => ErrorCode::Cancelled, + GatewayErrorKind::UpstreamUnavailable + | GatewayErrorKind::ProviderInfrastructureUnavailable => ErrorCode::Upstream, + _ => ErrorCode::Fault, + }; + let mut fault = PluginFault::new(code, error.client_message()); + fault.details = Some(json!({ + "kind": error.kind().as_str(), + "type": error.client_error_type(), + "code": error.client_error_code(), + "retry_after_ms": error.retry_after().map(|value| value.as_millis()), + "diagnostic": error.diagnostic().map(|value| json!({ + "message": value.as_str(), "stage": value.stage(), "code": value.code(), + })), + })); + fault +} + +fn provider(error: &ProviderError) -> PluginFault { + let mut fault = gateway(GatewayError::from_provider(error)); + fault.send_state = match error.send_state() { + UpstreamSendState::NotSent => SendState::NotSent, + UpstreamSendState::Sent => SendState::Sent, + UpstreamSendState::Ambiguous => SendState::Ambiguous, + }; + fault.http_status = error.upstream_status(); + fault.details.get_or_insert_with(|| json!({}))["provider"] = json!({ + "kind": error.kind().as_str(), + "code": error.upstream_code().map(|value| value.as_str()), + "request_id": error.upstream_request_id().map(|value| value.as_str()), + "retry_after_ms": error.retry_after().map(|value| value.as_millis()), + "raw_upstream_error": error.raw_upstream_error().map(|value| value.as_str()), + "response": error.client_visible_upstream_response().map(|value| json!({ + "status": value.status(), "content_type": value.content_type(), + "headers": value.headers().iter().map(|header| json!({"name":header.name(), "value":header.value().as_ref()})).collect::>(), + "body": value.body().as_ref(), + })), + "connection": error.connection_observation().map(|value| json!({ + "connection_id": value.connection_id(), "exit_reason": value.exit_reason(), + "age_ms": value.age_ms(), "idle_ms": value.idle_ms(), + })), + "continuation_unavailable_reason": error.continuation_unavailable_reason(), + "continuation_failure": error.continuation_failure().map(|value| match value { + ContinuationFailure::Busy => "busy", + ContinuationFailure::HistoryUnavailable => "history_unavailable", + }), + "continuation_recovery_disposition": error.continuation_recovery_disposition().map(|value| match value { + ContinuationRecoveryDisposition::RetryExactConnection => "retry_exact_connection", + ContinuationRecoveryDisposition::ClientReplayRequired => "client_replay_required", + ContinuationRecoveryDisposition::ProviderReplayAllowed => "provider_replay_allowed", + }), + "pre_delivery_retry": error.pre_delivery_retry().map(|value| match value { + PreDeliveryRetry::AccountRotation => json!({"kind":"account_rotation"}), + PreDeliveryRetry::SameAccountTransportFallback => json!({"kind":"same_account_transport_fallback"}), + PreDeliveryRetry::SameAccountConnectionRetry {transport} => json!({"kind":"same_account_connection_retry","transport": match transport { + gateway_core::engine::AttemptTransport::Default => json!({"kind":"default"}), + gateway_core::engine::AttemptTransport::Retry(index) => json!({"kind":"retry","index":index.get()}), + gateway_core::engine::AttemptTransport::Fallback => json!({"kind":"fallback"}), + }}), + PreDeliveryRetry::SameAccountTransportRetry {retry_index,delay} => json!({"kind":"same_account_transport_retry","retry_index":retry_index.get(),"delay_ms":delay.as_millis()}), + PreDeliveryRetry::SameAccountTransientRetry {max_retries,initial_delay,max_delay} => json!({"kind":"same_account_transient_retry","max_retries":max_retries.get(),"initial_delay_ms":initial_delay.as_millis(),"max_delay_ms":max_delay.as_millis()}), + }), + "replay_safe": error.replay_is_safe(), + "retry_same_account": error.retries_same_account(), + "credential_recovery_required": error.requires_credential_recovery(), + "sensitive_context_redacted": error.sensitive_context_was_redacted(), + "has_atomic_client_events": error.has_atomic_client_events(), + }); + fault +} + +pub(super) fn engine(error: &EngineError) -> PluginFault { + match error { + EngineError::Provider(error) => provider(error), + _ => { + let mut fault = gateway(gateway_error_from_engine(error)); + fault.details.get_or_insert_with(|| json!({}))["engine"] = + json!({"message":error.to_string()}); + fault + } + } +} + +pub(super) fn middleware(error: &MiddlewareError) -> PluginFault { + match error { + MiddlewareError::Gateway(error) => gateway(error.clone()), + MiddlewareError::Engine(error) => engine(error), + MiddlewareError::Provider(error) => provider(error), + MiddlewareError::Rejected => PluginFault::new(ErrorCode::Rejected, error.to_string()), + MiddlewareError::InvalidState => PluginFault::new(ErrorCode::Conflict, error.to_string()), + MiddlewareError::Fault => PluginFault::new(ErrorCode::Fault, error.to_string()), + MiddlewareError::Remote { source: error, .. } => { + match error.downcast_ref::() { + Some(error) => rpc(error), + None => match error.downcast_ref::>() { + Some(error) => middleware(error), + None => PluginFault::new(ErrorCode::Fault, error.to_string()), + }, + } + } + } +} + +pub(crate) fn rpc(error: &crate::RpcError) -> PluginFault { + use crate::RpcError; + let code = match error { + RpcError::Remote(fault) => return fault.clone(), + RpcError::Timeout => ErrorCode::Timeout, + RpcError::Cancelled => ErrorCode::Cancelled, + RpcError::Capacity => ErrorCode::Capacity, + _ => ErrorCode::Fault, + }; + PluginFault::new(code, error.to_string()) +} + +pub(crate) fn rpc_middleware(error: crate::RpcError) -> MiddlewareError { + let rejected = + matches!(&error, crate::RpcError::Remote(fault) if fault.code == ErrorCode::Rejected); + MiddlewareError::Remote { + source: Box::new(error), + rejected, + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/facts/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/facts/mod.rs new file mode 100644 index 000000000..6c142b936 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/facts/mod.rs @@ -0,0 +1,288 @@ +use gateway_core::{ + engine::provider::ProviderCallMetadata, + event::{ + ContentKind, FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, + ProviderResponseObservation, + }, + metering::{CalculatedCostBreakdown, Money}, + operation::ProviderSessionState, +}; +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::{ + middleware::MiddlewareHeader, + model::{ + CanonicalEvent, ContentKind as WireContentKind, ExecutionEvent, + FinishReason as WireFinishReason, Usage as WireUsage, WireEvent, WirePayload, + facts::{self, ExecutionCost, ExecutionFacts}, + }, + observation::{RequestMoney, RequestTimings}, + }, +}; + +pub(crate) fn execution(event: &ProviderEvent) -> Result { + Ok(ExecutionFacts { + costs: event + .canonical_facts() + .iter() + .filter_map(|fact| match fact { + GatewayEvent::CalculatedCost(cost) => { + let estimate = cost.clone().into_estimate(); + Some(ExecutionCost::Calculated { + total: money(cost.total()), + breakdown: estimate.breakdown().map(breakdown).map(Box::new), + }) + } + GatewayEvent::ProviderCost(cost) => Some(ExecutionCost::ProviderReported { + total: money(cost.total()), + }), + _ => None, + }) + .collect(), + observation: event.response_observation().map(observation), + session_update: event.session_update().map(session), + middleware_transformed: event.middleware_transformed(), + middleware_origin_wire: event + .middleware_origin_wire() + .map(project_wire) + .transpose()?, + }) +} + +pub(crate) fn metadata(value: &ProviderCallMetadata) -> facts::ProviderCallMetadata { + facts::ProviderCallMetadata { + provider: value.provider().as_str().to_owned(), + upstream_model: value + .upstream_model() + .map(|value| value.as_str().to_owned()), + provider_account_id: value.provider_account_id().as_str().to_owned(), + upstream_request_id: value + .upstream_request_id() + .map(|value| value.as_str().to_owned()), + transport: value.transport().as_str().to_owned(), + selection_observation: value.selection_observation().map(|value| { + facts::SelectionObservation { + account_selection_wait_ms: value.account_selection_wait_ms(), + capacity: value.capacity().map(|value| facts::AccountCapacity { + used_slots: value.used_slots(), + total_slots: value.total_slots(), + }), + } + }), + } +} + +fn money(value: Money) -> RequestMoney { + RequestMoney { + amount: value.amount().to_string(), + currency: value.currency().as_str().to_owned(), + } +} + +fn breakdown(value: &CalculatedCostBreakdown) -> facts::CostBreakdown { + facts::CostBreakdown { + long_context_billing_applied: value.long_context_billing_applied(), + image: value.image().map(|value| facts::ImageCostBreakdown { + input_tokens: value.input_tokens, + cached_tokens: value.cached_tokens, + input_amount: money(value.input_amount), + cache_read_amount: money(value.cache_read_amount), + input_price_per_million: money(value.input_price_per_million), + cache_read_price_per_million: money(value.cache_read_price_per_million), + }), + input_amount: money(value.input_amount()), + output_amount: money(value.output_amount()), + cache_read_amount: money(value.cache_read_amount()), + cache_write_amount: money(value.cache_write_amount()), + standard_amount: money(value.standard_amount()), + total_amount: money(value.total_amount()), + input_price_per_million: money(value.input_price_per_million()), + output_price_per_million: money(value.output_price_per_million()), + cache_read_price_per_million: money(value.cache_read_price_per_million()), + cache_write_price_per_million: money(value.cache_write_price_per_million()), + service_tier: value.service_tier().map(str::to_owned), + multiplier_percent: value.multiplier_percent(), + custom_multiplier_bps: value.custom_multiplier_bps(), + } +} + +fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservation { + let timings = value.timings(); + facts::ResponseObservation { + transport: value.transport().as_str().to_owned(), + http_version: value.http_version().map(|value| value.as_str().to_owned()), + websocket_pool: value + .websocket_pool() + .map(|value| value.as_str().to_owned()), + status_code: value.status_code(), + request_id: value.request_id().map(|value| value.as_str().to_owned()), + service_tier: value.service_tier().map(str::to_owned), + upstream_response_model: value.upstream_response_model().map(str::to_owned), + timings: RequestTimings { + transport_decision_wait_ms: timings.transport_decision_wait_ms, + connect_ms: timings.connect_ms, + headers_ms: timings.headers_ms, + first_event_ms: timings.first_event_ms, + first_reasoning_ms: timings.first_reasoning_ms, + first_text_ms: timings.first_text_ms, + first_token_ms: timings.first_token_ms, + provider_processing_ms: timings.provider_processing_ms, + latency_ms: None, + }, + client_headers: value + .client_headers() + .iter() + .map(|value| MiddlewareHeader { + name: value.name().to_owned(), + value: value.value().to_vec(), + }) + .collect(), + provider_metadata: value + .provider_metadata() + .map(|value| value.as_json().to_owned()), + } +} + +fn session(value: &ProviderSessionState) -> facts::SessionState { + facts::SessionState { + provider: value.provider().to_owned(), + payload: value.payload().clone(), + extension_owner: value.extension_owner().map(|value| facts::SessionOwner { + instance_id: value.instance_id.clone(), + contribution_id: value.contribution_id.clone(), + adapter_id: value.adapter_id.clone(), + generation: value.generation, + incarnation: value.incarnation.clone(), + connection_local: value.connection_local, + }), + } +} + +pub(crate) fn event(event: ProviderEvent) -> Result { + let host = Some(Box::new(execution(&event)?)); + let (facts, wire) = event.into_parts(); + Ok(ExecutionEvent { + facts: facts + .into_iter() + .filter_map(project_fact) + .collect::, _>>()?, + wire: wire.as_ref().map(project_wire).transpose()?, + host, + }) +} + +pub(crate) fn snapshot(event: &ProviderEvent) -> Result { + let facts = event + .canonical_facts() + .iter() + .cloned() + .filter_map(project_fact) + .collect::, _>>()?; + Ok(ExecutionEvent { + facts, + wire: event.wire_event().map(project_wire).transpose()?, + host: Some(Box::new(execution(event)?)), + }) +} + +fn project_fact(event: GatewayEvent) -> Option> { + Some(Ok(match event { + GatewayEvent::Started(meta) => CanonicalEvent::Started { + id: meta.response_id().to_owned(), + model: meta.model().map(str::to_owned), + }, + GatewayEvent::ContentAdded(item) => CanonicalEvent::ContentAdded { + index: item.index(), + kind: match item.kind() { + ContentKind::Text => WireContentKind::Text, + ContentKind::Reasoning => WireContentKind::Reasoning, + ContentKind::ToolCall => WireContentKind::ToolCall, + ContentKind::Image => WireContentKind::Image, + ContentKind::Audio => WireContentKind::Audio, + _ => { + return Some(Err(PluginFault::new( + ErrorCode::Unsupported, + "unknown content kind", + ))); + } + }, + }, + GatewayEvent::TextDelta(delta) => CanonicalEvent::TextDelta { + index: delta.content_index, + text: delta.text, + }, + GatewayEvent::ReasoningDelta(delta) => CanonicalEvent::ReasoningDelta { + index: delta.content_index, + text: delta.text, + }, + GatewayEvent::ToolCallDelta(delta) => CanonicalEvent::ToolCallDelta { + index: delta.content_index, + id: delta.call_id, + name: delta.name, + arguments: delta.arguments_delta, + }, + GatewayEvent::Usage(usage) => CanonicalEvent::Usage { + usage: WireUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cached_tokens: usage.cached_tokens, + cache_write_tokens: usage.cache_write_tokens, + reasoning_tokens: usage.reasoning_tokens, + image_input_tokens: usage.image_input_tokens, + image_output_tokens: usage.image_output_tokens, + total_tokens: usage.total_tokens, + }, + }, + // 费用随 host 快照完整开放,结算仍由原执行完成。 + GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => return None, + GatewayEvent::Completed(meta) => CanonicalEvent::Completed { + id: meta.response_id().to_owned(), + model: meta.model().map(str::to_owned), + reason: match meta.finish_reason().unwrap_or(FinishReason::Other) { + FinishReason::Stop => WireFinishReason::Stop, + FinishReason::Length => WireFinishReason::Length, + FinishReason::ToolCall => WireFinishReason::ToolCall, + FinishReason::ContentFilter => WireFinishReason::ContentFilter, + FinishReason::Other => WireFinishReason::Other, + _ => WireFinishReason::Other, + }, + }, + _ => { + return Some(Err(PluginFault::new( + ErrorCode::Unsupported, + "unknown model event", + ))); + } + })) +} + +fn project_wire(wire: &ProtocolWireEvent) -> Result { + let protocol = wire.protocol().to_owned(); + let payload = if let Some(body) = wire.raw_http_body_bytes() { + WirePayload::RawBody { + body: body.to_vec(), + } + } else if let Some(body) = wire.raw_json_body() { + WirePayload::RawJson { + body: body.to_vec(), + } + } else if wire.has_json_data() { + WirePayload::Json { + event: wire.event_type().map(str::to_owned), + data: wire.data().clone(), + id: wire.sse_id().map(str::to_owned), + retry: wire.sse_retry(), + raw_sse: wire.raw_sse_frame().map(|frame| frame.to_vec()), + } + } else if let Some(frame) = wire.raw_sse_frame() { + WirePayload::RawSse { + frame: frame.to_vec(), + } + } else { + return Err(PluginFault::new( + ErrorCode::Fault, + "model wire event has no payload", + )); + }; + Ok(WireEvent { protocol, payload }) +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http.rs b/backend/crates/gateway-plugin/runtime/src/callback/http.rs index 869d32e4a..97c3c2570 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/http.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/http.rs @@ -1,18 +1,18 @@ use std::sync::Arc; use gateway_core::upstream::UpstreamSendState; -use gateway_host::outbound::{HttpClient, HttpError, HttpErrorKind, HttpRequest}; +use gateway_host::outbound::{HttpClient, HttpError, HttpErrorKind, HttpRequest, NetworkPolicy}; use gateway_plugin_sdk::{ErrorCode, PluginFault, SendState, call::host as wire}; use serde::de::DeserializeOwned; -use super::{CallResources, HttpAuthorization, HttpStream, NetworkScope, denied, invalid}; +use super::{CallResources, CallbackScope, HttpStream, denied, invalid}; use crate::RpcReply; /// 回调的公共网络外层;账号鉴权只装饰请求,发送、读流和回收共用同一终端。 pub(super) struct HttpCallbacks<'a> { pub(super) client: &'a HttpClient, - pub(super) authorization: &'a HttpAuthorization, - pub(super) scope: &'a Arc, + pub(super) network: &'a NetworkPolicy, + pub(super) scope: &'a Arc, pub(super) call: &'a CallResources, pub(super) maximum_payload: usize, } @@ -26,23 +26,15 @@ impl HttpCallbacks<'_> { ) -> Result { let Self { client, - authorization, + network, scope, call, maximum_payload, } = *self; - let timeout = call - .deadline - .checked_duration_since(tokio::time::Instant::now()) - .ok_or_else(|| PluginFault::new(ErrorCode::Timeout, "callback deadline elapsed"))?; + let timeout = call.timeout()?; let attempt = scope.start_upstream(upstream_purpose); let response = client - .open( - request, - scope.proxy.as_ref(), - &authorization.network, - timeout, - ) + .open(request, scope.proxy.as_ref(), network, timeout) .await; attempt.finish( response diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs b/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs new file mode 100644 index 000000000..d5180641e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs @@ -0,0 +1,72 @@ +//! 主动 HTTP 子调用只进入 API 分派端口,正文复用当前 RPC 的资源池。 +use super::{CallResources, http_middleware, invalid, services::ServicePorts}; +use crate::RpcReply; +use gateway_core::middleware::http as core; +use gateway_plugin_sdk::{CallContext, PluginFault, call::middleware::http as wire}; + +pub(super) async fn call( + services: &ServicePorts, + context: &CallContext, + call: &CallResources, + method: &str, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, +) -> Result { + if method != wire::DISPATCH_METHOD { + return call + .http_resources + .call(method, params, payload, maximum_payload) + .await; + } + let request: wire::Request = serde_json::from_value(params).map_err(|_| invalid())?; + let method = request.method.parse().map_err(|_| invalid())?; + let uri: http::Uri = request.uri.parse().map_err(|_| invalid())?; + if uri.scheme().is_some() || uri.authority().is_some() || !uri.path().starts_with('/') { + return Err(invalid()); + } + let headers = http_middleware::headers(request.headers)?; + let dispatcher = services.http()?; + let settings = call + .request_settings() + .or_else(|| dispatcher.request_settings()); + let settings = http_middleware::resolve_settings( + settings, + request.settings, + request.timeout_ms, + &context.instance_id, + )?; + let parent = call.scope.origin.as_ref(); + let child = core::Context { + plugin_instance_id: Some(context.instance_id.clone()), + request_id: context + .request_id + .clone() + .unwrap_or_else(|| context.resource_scope_id.clone()), + call_id: uuid::Uuid::now_v7().to_string(), + parent_call_id: Some(parent.map_or_else( + || format!("{}:{}", context.resource_scope_id, context.call_id), + |parent| parent.call_id.clone(), + )), + extensions: call.scope.child_extensions(&context.instance_id)?, + // 句柄可以在父 RPC End 后交给 HTTP 发送端;生命周期属于父调用和响应正文,不能在 RPC End 提前取消。 + cancellation: parent.map_or_else( + || call.cancellation.child_token(), + |parent| parent.cancellation.child_token(), + ), + plan: parent.map(|parent| parent.plan.clone()), + }; + let body = call.http_resources.body(request.body, payload).await?; + let mut http = core::Request::new(body); + *http.method_mut() = method; + *http.uri_mut() = uri; + *http.headers_mut() = headers; + *http.version_mut() = http_middleware::version(request.version); + http.extensions_mut().insert(settings); + call.scope.observe_http_dispatch(); + let response = dispatcher + .dispatch(child, http) + .await + .map_err(|error| super::error::middleware(&error))?; + call.http_resources.response(response) +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/mod.rs new file mode 100644 index 000000000..d3f562820 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/mod.rs @@ -0,0 +1,269 @@ +//! HTTP 边界只搬运类型化请求、响应与正文资源;业务默认实现仍由 API 持有。 + +pub(crate) mod resources; +mod websocket; + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use futures::future::BoxFuture; +use gateway_core::{engine::middleware::MiddlewareError, middleware::http as core}; +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::middleware::{MiddlewareHeader, NEXT_METHOD, http as wire}, +}; +use http::{HeaderMap, HeaderName, HeaderValue, Version}; + +use super::MiddlewareCallback; +use crate::RpcReply; + +pub(crate) struct Invocation { + instance_id: String, + state: Mutex, + resources: Arc, + session: Mutex>, +} + +struct State { + settings: Option, + request: Option, + next: Option, +} + +pub(super) fn resolve_settings( + settings: Option, + value: serde_json::Value, + timeout_ms: Option, + instance_id: &str, +) -> Result { + let settings = match (settings, value.is_null()) { + (Some(settings), false) => Ok(Some( + settings + .replace( + serde_json::from_value(value).map_err(|_| invalid())?, + instance_id, + ) + .map_err(|_| invalid())?, + )), + (settings, true) => Ok(settings), + (None, false) => Err(invalid()), + }?; + let runtime = settings + .map(|settings| { + settings + .replace_http_timeout(timeout_ms, instance_id) + .map_err(|_| invalid()) + }) + .transpose()?; + Ok(core::Settings { + runtime, + timeout: timeout_ms.map(Duration::from_millis), + }) +} + +impl Invocation { + pub(crate) fn new( + request: core::Request, + next: core::Next, + instance_id: String, + ) -> Result<(Arc, wire::Request), MiddlewareError> { + let (parts, body) = request.into_parts(); + let settings = parts + .extensions + .get::() + .and_then(|settings| settings.runtime.clone()); + let head = wire::Request { + settings: serde_json::to_value(settings.as_ref().map(|settings| settings.values())) + .map_err(|_| MiddlewareError::InvalidState)?, + method: parts.method.to_string(), + uri: parts.uri.to_string(), + version: wire_version(parts.version)?, + headers: wire_headers(&parts.headers), + timeout_ms: parts + .extensions + .get::() + .and_then(|settings| settings.timeout) + .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)), + body: wire::Body::Empty, + }; + let invocation = Arc::new(Self { + instance_id, + state: Mutex::new(State { + settings, + request: Some(parts), + next: Some(next), + }), + resources: Arc::new(resources::Resources::default()), + session: Mutex::new(None), + }); + let body = invocation.resources.insert_body(body); + Ok((invocation, wire::Request { body, ..head })) + } + + async fn next( + &self, + request: wire::Request, + payload: Vec, + ) -> Result { + // 先校验完整 HTTP 值,再消费唯一续体;请求 extensions(包括连接升级)原样搬运。 + let method = request.method.parse().map_err(|_| invalid())?; + let uri = request.uri.parse().map_err(|_| invalid())?; + let headers = headers(request.headers)?; + let settings = resolve_settings( + self.request_settings(), + request.settings, + request.timeout_ms, + &self.instance_id, + )?; + let body = self.resources.body(request.body, payload).await?; + let (mut parts, next) = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let next = state.next.take().ok_or_else(unavailable)?; + let parts = state.request.take().ok_or_else(unavailable)?; + state.settings = settings.runtime.clone(); + (parts, next) + }; + parts.method = method; + parts.uri = uri; + parts.version = version(request.version); + parts.headers = headers; + parts.extensions.insert(settings); + let response = next + .run(core::Request::from_parts(parts, body)) + .await + .map_err(|error| super::error::middleware(&error))?; + self.resources.response(response) + } + + pub(crate) async fn complete( + &self, + response: wire::Response, + payload: Vec, + stream_body: Option, + ) -> Result { + let status = http::StatusCode::from_u16(response.status) + .map_err(|_| MiddlewareError::InvalidState)?; + let headers = headers(response.headers).map_err(|_| MiddlewareError::InvalidState)?; + let body = if matches!(response.body, wire::Body::Stream) && payload.is_empty() { + stream_body.ok_or(MiddlewareError::InvalidState)? + } else { + self.resources + .body(response.body, payload) + .await + .map_err(|_| MiddlewareError::InvalidState)? + }; + let mut result = match response.response { + Some(source) => { + core::Response::from_parts(self.resources.take_response(&source)?, body) + } + None => core::Response::new(body), + }; + *result.status_mut() = status; + *result.version_mut() = version(response.version); + *result.headers_mut() = headers; + Ok(result) + } +} + +impl MiddlewareCallback for Invocation { + fn request_settings(&self) -> Option { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .settings + .clone() + } + + fn http_resources(&self) -> Option> { + Some(self.resources.clone()) + } + + fn invoke( + self: Arc, + method: String, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + if self.is_session_method(&method, ¶ms) { + return self + .session_call(method, params, payload, maximum_payload) + .await; + } + match method.as_str() { + wire::UPGRADE_METHOD if payload.is_empty() => { + self.upgrade(serde_json::from_value(params).map_err(|_| invalid())?) + .await + } + NEXT_METHOD => { + self.next( + serde_json::from_value(params).map_err(|_| invalid())?, + payload, + ) + .await + } + _ => Err(invalid()), + } + }) + } +} + +pub(crate) fn wire_headers(headers: &HeaderMap) -> Vec { + headers + .iter() + .map(|(name, value)| MiddlewareHeader { + name: name.as_str().to_owned(), + value: value.as_bytes().to_vec(), + }) + .collect() +} + +pub(crate) fn headers(headers: Vec) -> Result { + let mut result = HeaderMap::new(); + for header in headers { + result + .try_append( + HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| invalid())?, + HeaderValue::from_bytes(&header.value).map_err(|_| invalid())?, + ) + .map_err(|_| invalid())?; + } + Ok(result) +} + +fn wire_version(version: Version) -> Result { + Ok(match version { + Version::HTTP_09 => wire::Version::Http09, + Version::HTTP_10 => wire::Version::Http10, + Version::HTTP_11 => wire::Version::Http11, + Version::HTTP_2 => wire::Version::Http2, + Version::HTTP_3 => wire::Version::Http3, + _ => return Err(MiddlewareError::InvalidState), + }) +} + +pub(super) fn version(version: wire::Version) -> Version { + match version { + wire::Version::Http09 => Version::HTTP_09, + wire::Version::Http10 => Version::HTTP_10, + wire::Version::Http11 => Version::HTTP_11, + wire::Version::Http2 => Version::HTTP_2, + wire::Version::Http3 => Version::HTTP_3, + } +} + +fn invalid() -> PluginFault { + PluginFault::new(ErrorCode::InvalidInput, "HTTP middleware input is invalid") +} +fn unavailable() -> PluginFault { + PluginFault::new( + ErrorCode::Conflict, + "HTTP middleware resource was consumed or closed", + ) +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/resources.rs b/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/resources.rs new file mode 100644 index 000000000..cad7cfad7 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/resources.rs @@ -0,0 +1,384 @@ +//! HTTP 正文与响应部件共享同一调用资源池;next 与主动分派复用句柄和背压。 +use super::*; +use bytes::Bytes; +use futures::StreamExt as _; +use gateway_plugin_sdk::call::middleware::{MiddlewareBodyClose, MiddlewareBodyRead}; +use http_body::Frame; +use http_body_util::{BodyExt as _, BodyStream, Full, StreamBody}; +use std::{collections::BTreeMap, sync::Weak}; + +#[derive(Default)] +pub(crate) struct Resources { + entries: Mutex, +} + +#[derive(Default)] +struct Entries { + bodies: BTreeMap>, + pipes: BTreeMap>, + responses: BTreeMap, +} +struct BodyResource { + cancellation: gateway_core::lifecycle::CancellationToken, + state: tokio::sync::Mutex, +} + +struct BodyPipe { + sender: tokio::sync::Mutex>>>>, + cancellation: gateway_core::lifecycle::CancellationToken, +} + +impl Drop for BodyPipe { + fn drop(&mut self) { + if self.sender.get_mut().is_some() { + self.cancellation.cancel(); + } + } +} + +struct PipeReader { + receiver: tokio::sync::mpsc::Receiver>>, + cancellation: gateway_core::lifecycle::CancellationToken, + resources: Weak, + handle: String, +} + +impl Drop for PipeReader { + fn drop(&mut self) { + // 下游可以提前返回;消费端退出即归还管道名额,并唤醒仍在背压中等待的写入。 + self.cancellation.cancel(); + if let Some(resources) = self.resources.upgrade() { + resources + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pipes + .remove(&self.handle); + } + } +} + +struct BodyState { + body: Option, + pending: Option, +} + +impl Resources { + pub(super) fn insert_body(&self, body: core::Body) -> wire::Body { + let handle = uuid::Uuid::new_v4().to_string(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bodies + .insert( + handle.clone(), + Arc::new(BodyResource { + cancellation: Default::default(), + state: tokio::sync::Mutex::new(BodyState { + body: Some(body), + pending: None, + }), + }), + ); + wire::Body::Handle { handle } + } + + pub(crate) async fn body( + &self, + source: wire::Body, + payload: Vec, + ) -> Result { + match source { + wire::Body::Empty if payload.is_empty() => Ok(core::empty_body()), + wire::Body::Bytes => Ok(Full::new(Bytes::from(payload)) + .map_err(|never| match never {}) + .boxed_unsync()), + wire::Body::Handle { handle } if payload.is_empty() => { + let resource = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bodies + .get(&handle) + .cloned() + .ok_or_else(unavailable)?; + let mut state = resource.state.lock().await; + let body = state.body.take().ok_or_else(unavailable)?; + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bodies + .remove(&handle); + Ok(if let Some(pending) = state.pending.take() { + StreamBody::new( + futures::stream::once(async { Ok(Frame::data(pending)) }) + .chain(BodyStream::new(body)), + ) + .boxed_unsync() + } else { + body + }) + } + _ => Err(invalid()), + } + } + + fn create_body(self: &Arc) -> Result { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if entries.pipes.len() >= 16 { + return Err(PluginFault::new( + ErrorCode::Capacity, + "HTTP upload capacity reached", + )); + } + let handle = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = tokio::sync::mpsc::channel(1); + let cancellation = gateway_core::lifecycle::CancellationToken::new(); + let reader = PipeReader { + receiver, + cancellation: cancellation.clone(), + resources: Arc::downgrade(self), + handle: handle.clone(), + }; + let body = StreamBody::new(futures::stream::try_unfold(reader, |mut reader| async move { + let message = tokio::select! { + biased; + () = reader.cancellation.cancelled() => return Err(Box::new(MiddlewareError::Fault) as Box), + message = reader.receiver.recv() => message, + }; + match message { + Some(Some(frame)) => Ok(Some((frame, reader))), + Some(None) => Ok(None), + None => Err(Box::new(MiddlewareError::Fault) as _), + } + })).boxed_unsync(); + entries.pipes.insert( + handle.clone(), + Arc::new(BodyPipe { + sender: tokio::sync::Mutex::new(Some(sender)), + cancellation, + }), + ); + entries.bodies.insert( + handle.clone(), + Arc::new(BodyResource { + cancellation: Default::default(), + state: tokio::sync::Mutex::new(BodyState { + body: Some(body), + pending: None, + }), + }), + ); + Ok(RpcReply { + result: serde_json::to_value(wire::BodyPipe { handle }).map_err(|_| invalid())?, + payload: Vec::new(), + }) + } + + async fn write( + &self, + write: wire::BodyWrite, + payload: Vec, + ) -> Result { + let frame = match (write.eof, write.trailers) { + (true, None) if payload.is_empty() => None, + (false, None) => Some(Frame::data(Bytes::from(payload))), + (false, Some(trailers)) if payload.is_empty() => { + Some(Frame::trailers(headers(trailers)?)) + } + _ => return Err(invalid()), + }; + let pipe = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pipes + .get(&write.handle) + .cloned() + .ok_or_else(unavailable)?; + let mut sender = pipe.sender.lock().await; + let sender_ref = sender.as_ref().ok_or_else(unavailable)?; + let result = tokio::select! { + biased; + () = pipe.cancellation.cancelled() => Err(unavailable()), + result = sender_ref.send(frame) => result.map_err(|_| unavailable()), + }; + if write.eof || result.is_err() { + sender.take(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pipes + .remove(&write.handle); + } + result?; + Ok(RpcReply { + result: serde_json::json!({}), + payload: Vec::new(), + }) + } + + async fn read( + &self, + read: MiddlewareBodyRead, + maximum_payload: usize, + ) -> Result { + let maximum = usize::try_from(read.maximum_bytes) + .map_err(|_| invalid())? + .min(maximum_payload); + if maximum == 0 { + return Err(invalid()); + } + let resource = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bodies + .get(&read.handle) + .cloned() + .ok_or_else(unavailable)?; + let read = async { + let mut state = resource.state.lock().await; + let frame = if let Some(bytes) = state.pending.take() { + Some(Ok(Frame::data(bytes))) + } else { + state.body.as_mut().ok_or_else(unavailable)?.frame().await + }; + if !matches!(&frame, Some(Ok(_))) { + // EOF 和读取失败均结束读取所有权;原响应部件仍可随替换后的正文返回。 + state.body.take(); + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bodies + .remove(&read.handle); + } + let frame = frame.transpose().map_err(|error| { + match error.downcast_ref::() { + Some(error) => crate::callback::error::middleware(error), + None => PluginFault::new(ErrorCode::Fault, error.to_string()), + } + })?; + let mut result = wire::BodyRead::default(); + let payload = match frame { + None => { + result.eof = true; + Vec::new() + } + Some(frame) => match frame.into_data() { + Ok(mut bytes) => { + let payload = bytes.split_to(bytes.len().min(maximum)).to_vec(); + if !bytes.is_empty() { + state.pending = Some(bytes); + } + payload + } + Err(frame) => { + result.trailers = + Some(wire_headers(&frame.into_trailers().map_err(|_| invalid())?)); + Vec::new() + } + }, + }; + Ok(RpcReply { + result: serde_json::to_value(result).map_err(|_| invalid())?, + payload, + }) + }; + tokio::select! { + biased; + () = resource.cancellation.cancelled() => Err(unavailable()), + result = read => result, + } + } + pub(crate) fn response(&self, response: core::Response) -> Result { + let (parts, body) = response.into_parts(); + let body = self.insert_body(body); + let wire::Body::Handle { handle: id } = &body else { + return Err(invalid()); + }; + let id = id.clone(); + let response = wire::Response { + status: parts.status.as_u16(), + version: wire_version(parts.version).map_err(|_| invalid())?, + headers: wire_headers(&parts.headers), + body, + response: Some(id.clone()), + session: false, + }; + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .responses + .insert(id, parts); + Ok(RpcReply { + result: serde_json::to_value(response).map_err(|_| invalid())?, + payload: Vec::new(), + }) + } + + pub(super) fn take_response(&self, id: &str) -> Result { + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .responses + .remove(id) + .ok_or(MiddlewareError::InvalidState) + } + + pub(crate) async fn call( + self: &Arc, + method: &str, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, + ) -> Result { + match method { + wire::BODY_CREATE_METHOD if payload.is_empty() && params == serde_json::json!({}) => { + self.create_body() + } + wire::BODY_WRITE_METHOD => { + self.write( + serde_json::from_value(params).map_err(|_| invalid())?, + payload, + ) + .await + } + wire::BODY_READ_METHOD if payload.is_empty() => { + self.read( + serde_json::from_value(params).map_err(|_| invalid())?, + maximum_payload, + ) + .await + } + wire::BODY_CLOSE_METHOD if payload.is_empty() => { + let close: MiddlewareBodyClose = + serde_json::from_value(params).map_err(|_| invalid())?; + let (body, pipe) = { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ( + entries.bodies.remove(&close.handle), + entries.pipes.remove(&close.handle), + ) + }; + if let Some(body) = body { + body.cancellation.cancel(); + } + if let Some(pipe) = pipe { + pipe.cancellation.cancel(); + } + Ok(RpcReply { + result: serde_json::json!({}), + payload: Vec::new(), + }) + } + _ => Err(invalid()), + } + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/websocket/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/websocket/mod.rs new file mode 100644 index 000000000..9d32e15fa --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/http_middleware/websocket/mod.rs @@ -0,0 +1,161 @@ +//! 握手消费当前 HTTP 续体;收发继续使用同一调用的受管消息资源。 + +use super::*; +use crate::callback::websocket_middleware::Invocation as Messages; +use gateway_core::middleware::{http::upgrade, websocket as ws}; +use gateway_plugin_sdk::call::middleware::websocket as message; +use gateway_plugin_sdk::call::middleware::{BODY_CLOSE_METHOD, BODY_READ_METHOD}; + +#[derive(Clone)] +pub(super) struct Session { + pending: upgrade::PendingSession, + status: http::StatusCode, + messages: Arc, +} +struct Sender(upgrade::PendingSession); +impl ws::Sender for Sender { + fn send(&self, message: ws::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + self.0 + .clone() + .await + .map_err(|error| MiddlewareError::Remote { + rejected: error.is_rejected(), + source: Box::new(error), + })? + .send(message) + .await + }) + } +} + +impl Invocation { + pub(super) async fn upgrade(&self, upgrade: wire::Upgrade) -> Result { + let request = upgrade.request; + let method = request.method.parse().map_err(|_| invalid())?; + let uri = request.uri.parse().map_err(|_| invalid())?; + let headers = headers(request.headers)?; + let settings = super::resolve_settings( + self.request_settings(), + request.settings, + request.timeout_ms, + &self.instance_id, + )?; + let mut parts = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.next.take().ok_or_else(unavailable)?; + state.settings = settings.runtime.clone(); + state.request.take().ok_or_else(unavailable)? + }; + let transport = parts + .extensions + .remove::>() + .ok_or_else(unavailable)?; + parts.method = method; + parts.uri = uri; + parts.version = version(request.version); + parts.headers = headers; + parts.extensions.insert(settings); + let (response, pending) = transport + .accept(parts, upgrade.protocols) + .await + .map_err(|_| invalid())?; + let status = response.status(); + let reply = self.resources.response(response)?; + *self + .session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Session { + status, + messages: Messages::session(Arc::new(Sender(pending.clone()))), + pending, + }); + Ok(reply) + } + + pub(crate) async fn session_ready(&self) -> Result<(), MiddlewareError> { + let pending = self + .session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .ok_or(MiddlewareError::InvalidState)? + .pending + .clone(); + pending.await.map_err(|error| MiddlewareError::Remote { + rejected: error.is_rejected(), + source: Box::new(error), + })?; + Ok(()) + } + + pub(crate) fn session_status(&self) -> Option { + self.session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|session| session.status) + } + + pub(super) fn is_session_method(&self, method: &str, params: &serde_json::Value) -> bool { + if matches!(method, wire::RECEIVE_METHOD | message::SEND_METHOD) { + return true; + } + matches!(method, BODY_READ_METHOD | BODY_CLOSE_METHOD) + && params + .get("handle") + .and_then(serde_json::Value::as_str) + .is_some_and(|handle| { + self.session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|session| session.messages.owns_payload(handle)) + }) + } + + pub(super) async fn session_call( + &self, + method: String, + params: serde_json::Value, + payload: Vec, + maximum: usize, + ) -> Result { + let session = self + .session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .ok_or_else(unavailable)?; + if method == wire::RECEIVE_METHOD { + if params != serde_json::json!({}) || !payload.is_empty() { + return Err(invalid()); + } + let socket = session + .pending + .await + .map_err(|error| crate::callback::error::middleware(&error))?; + let message = socket + .receive() + .await + .map_err(|error| crate::callback::error::middleware(&error))?; + Ok(RpcReply { + result: serde_json::to_value( + message + .map(|message| session.messages.encode(message)) + .transpose()?, + ) + .map_err(|_| invalid())?, + payload: Vec::new(), + }) + } else { + session + .messages + .invoke(method, params, payload, maximum) + .await + } + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/middleware/body.rs b/backend/crates/gateway-plugin/runtime/src/callback/middleware/body.rs index 88d7f9ac5..e99030005 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/middleware/body.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/middleware/body.rs @@ -9,7 +9,7 @@ use gateway_core::engine::middleware::{ }; use gateway_plugin_sdk::{ErrorCode, PluginFault, call::middleware::MiddlewareBodyDisposition}; -use super::{denied, invalid}; +use super::{invalid, resource_unavailable}; pub(super) struct BodyResource { expected_framing: MiddlewareFraming, @@ -91,6 +91,14 @@ impl MiddlewareBodyAuthority { if disposition == MiddlewareBodyDisposition::Drop && terminal { return Err(MiddlewareError::InvalidState); } + let framing = if framing == MiddlewareFraming::RawBytes + && source_bytes.is_empty() + && !bytes.is_empty() + { + self.0.expected_framing + } else { + framing + }; let output_transformed = transformed || disposition != MiddlewareBodyDisposition::Only || bytes != source_bytes; let mut frame = MiddlewareFrame::new(bytes, framing, finishes_source && terminal) @@ -179,7 +187,7 @@ impl BodyResource { released.await; }; state.read_started = true; - let body = state.body.as_mut().ok_or_else(denied)?; + let body = state.body.as_mut().ok_or_else(resource_unavailable)?; let frame = match body.next_frame().await { Ok(frame) => frame, Err(error) => { @@ -202,7 +210,10 @@ impl BodyResource { }; let transformed = frame.transformed(); let (bytes, framing, terminal, envelope) = frame.into_parts(); - if framing != self.expected_framing || bytes.len() > maximum { + // canonical-only 事件没有客户端正文,仍须保留并允许读取其完整事实。 + let facts_only = + framing == MiddlewareFraming::RawBytes && bytes.is_empty() && envelope.is_some(); + if (!facts_only && framing != self.expected_framing) || bytes.len() > maximum { return Err(invalid()); } let source_id = state.next_source_id.checked_add(1).ok_or_else(invalid)?; @@ -219,12 +230,34 @@ impl BodyResource { )); Ok(Some(BodyReadFrame { bytes, - framing, + framing: self.expected_framing, source_id, terminal, })) } + pub(super) async fn facts(&self, source_id: u64) -> Result>, PluginFault> { + let state = self.state.lock().await; + let (stored_id, source) = state.source.as_ref().ok_or_else(resource_unavailable)?; + if *stored_id != source_id { + return Err(invalid()); + } + source + .envelope + .as_ref() + .map(|envelope| { + super::super::facts::snapshot(envelope.event())? + .encode() + .map_err(|_| { + PluginFault::new( + ErrorCode::Capacity, + "middleware facts exceed the payload limit", + ) + }) + }) + .transpose() + } + pub(super) async fn take_unread(&self) -> Option> { let mut state = self.state.lock().await; if state.read_started { diff --git a/backend/crates/gateway-plugin/runtime/src/callback/middleware/headers.rs b/backend/crates/gateway-plugin/runtime/src/callback/middleware/headers.rs index c1f7a9651..f35fa1aea 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/middleware/headers.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/middleware/headers.rs @@ -4,65 +4,49 @@ use gateway_plugin_sdk::{ PluginFault, call::middleware::{MiddlewareHeader as WireHeader, MiddlewareHeaderMutation}, }; -use gateway_protocol::openai::{ - is_transport_managed_request_header, response_header_is_forwardable, -}; -use super::{denied, invalid}; +use super::invalid; const MAX_HEADERS: usize = 128; const MAX_HEADER_NAME_BYTES: usize = 128; const MAX_HEADER_VALUE_BYTES: usize = 16 * 1024; const MAX_HEADER_TOTAL_BYTES: usize = 64 * 1024; -#[derive(Clone, Copy)] -pub(super) enum HeaderDirection { - Request, - Response, -} - pub(super) fn apply_header_mutations( headers: &mut Vec, mutations: &[MiddlewareHeaderMutation], - direction: HeaderDirection, ) -> Result<(), PluginFault> { - if mutations.len() > MAX_HEADERS { + // SDK 的完整替换会先删除原始集合,再追加新的集合。 + if mutations.len() > MAX_HEADERS * 2 { return Err(invalid()); } validate_headers(headers)?; - let connection_options = connection_options(headers); for mutation in mutations { let (name, value) = match mutation { - MiddlewareHeaderMutation::Remove { name } => (normalized_header_name(name)?, None), + MiddlewareHeaderMutation::Remove { name } => (validated_header_name(name)?, None), MiddlewareHeaderMutation::Append { name, value } => { if value.len() > MAX_HEADER_VALUE_BYTES { return Err(invalid()); } - (normalized_header_name(name)?, Some(value.clone())) + (validated_header_name(name)?, Some(value.clone())) } }; - if name == "x-gateway-request-id" - || !header_is_visible(&name, direction) - || connection_options.iter().any(|option| option == &name) - { - return Err(denied()); - } match value { - None => headers.retain(|header| !header.name().eq_ignore_ascii_case(&name)), - Some(value) => headers.push(MiddlewareHeader::new(name, Bytes::from(value))), + None => headers.retain(|header| !header.name().eq_ignore_ascii_case(name)), + Some(value) => headers.push(MiddlewareHeader::new(name.to_owned(), Bytes::from(value))), } } validate_headers(headers) } -/// 原始头保留给宿主;这里检查格式与大小,可见性和写权限由各自边界控制。 +/// 保留完整 header 集合,只检查传输格式与资源大小。 pub(super) fn validate_headers(headers: &[MiddlewareHeader]) -> Result<(), PluginFault> { if headers.len() > MAX_HEADERS { return Err(invalid()); } let mut total = 0_usize; for header in headers { - let name = normalized_header_name(header.name())?; + let name = validated_header_name(header.name())?; if header.value().len() > MAX_HEADER_VALUE_BYTES || header .value() @@ -82,29 +66,18 @@ pub(super) fn validate_headers(headers: &[MiddlewareHeader]) -> Result<(), Plugi Ok(()) } -pub(super) fn project_headers( - headers: &[MiddlewareHeader], - direction: HeaderDirection, -) -> Result, PluginFault> { +pub(super) fn wire_headers(headers: &[MiddlewareHeader]) -> Result, PluginFault> { validate_headers(headers)?; - let connection_options = connection_options(headers); - let mut projected = Vec::with_capacity(headers.len()); - for header in headers { - let name = normalized_header_name(header.name())?; - if header_is_visible(&name, direction) - && !connection_options.iter().any(|option| option == &name) - { - projected.push(WireHeader { - name, - value: header.value().to_vec(), - }); - } - } - Ok(projected) + Ok(headers + .iter() + .map(|header| WireHeader { + name: header.name().to_owned(), + value: header.value().to_vec(), + }) + .collect()) } -fn normalized_header_name(name: &str) -> Result { - let name = name.to_ascii_lowercase(); +fn validated_header_name(name: &str) -> Result<&str, PluginFault> { if name.is_empty() || name.len() > MAX_HEADER_NAME_BYTES || !name @@ -115,44 +88,3 @@ fn normalized_header_name(name: &str) -> Result { } Ok(name) } - -fn header_is_visible(name: &str, direction: HeaderDirection) -> bool { - !sensitive_or_identity_header(name) - && match direction { - HeaderDirection::Request => !is_transport_managed_request_header(name), - HeaderDirection::Response => response_header_is_forwardable(name, &[]), - } -} - -fn connection_options(headers: &[MiddlewareHeader]) -> Vec { - headers - .iter() - .filter(|header| header.name().eq_ignore_ascii_case("connection")) - .filter_map(|header| std::str::from_utf8(header.value()).ok()) - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(str::to_ascii_lowercase) - .collect() -} - -fn sensitive_or_identity_header(name: &str) -> bool { - name == "x-openai-fedramp" - || name.contains("auth") - || name.contains("credential") - || name.contains("secret") - || name.contains("token") - || name.contains("cookie") - || name.contains("session") - || name.contains("conversation") - || name.contains("thread") - || name.contains("account") - || name.contains("organization") - || name.contains("project") - || name.contains("tenant") - || name.contains("principal") - || name.contains("identity") - || name.contains("user-id") - || name.ends_with("-key") - || name.ends_with("_key") -} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs index f4d4fe319..c7f8ca294 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs @@ -1,8 +1,48 @@ mod body; mod headers; +use futures::future::BoxFuture; use std::sync::{Arc, Mutex}; +pub(crate) trait MiddlewareCallback: Send + Sync { + fn request_settings(&self) -> Option { + None + } + + fn http_resources(&self) -> Option> { + None + } + + fn invoke( + self: Arc, + method: String, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, + ) -> BoxFuture<'static, Result>; +} + +impl MiddlewareCallback for MiddlewareInvocation { + fn request_settings(&self) -> Option { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .original_request + .settings() + .cloned() + } + + fn invoke( + self: Arc, + method: String, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { self.call(&method, params, payload, maximum_payload).await }) + } +} + use bytes::Bytes; use gateway_core::engine::{ execution::ClientTransport, @@ -14,7 +54,8 @@ use gateway_core::engine::{ use gateway_plugin_sdk::{ ErrorCode, PluginFault, call::middleware::{ - BODY_CLOSE_METHOD, BODY_READ_METHOD, MiddlewareBodyClose, MiddlewareBodyCloseResult, + BODY_CLOSE_METHOD, BODY_FACTS_METHOD, BODY_READ_METHOD, MiddlewareBodyClose, + MiddlewareBodyCloseResult, MiddlewareBodyFacts, MiddlewareBodyFactsResult, MiddlewareBodyFraming, MiddlewareBodyHandle, MiddlewareBodyRead, MiddlewareBodyReadResult, MiddlewareHeader as WireHeader, MiddlewareNextRequest, MiddlewareNextResponse, MiddlewareRequestBody, MiddlewareResponseBody, MiddlewareResponseHead, NEXT_METHOD, @@ -26,7 +67,7 @@ use crate::RpcReply; use super::PluginCallbacks; use body::BodyResource; pub(crate) use body::MiddlewareBodyAuthority; -use headers::{HeaderDirection, apply_header_mutations, project_headers, validate_headers}; +use headers::{apply_header_mutations, wire_headers}; fn request_feature( feature: gateway_plugin_sdk::call::middleware::RequestFeature, @@ -42,7 +83,7 @@ fn request_feature( } pub(crate) struct MiddlewareInvocation { - requests_authorized: bool, + instance_id: String, capabilities_allowed: bool, transport: ClientTransport, state: Mutex, @@ -52,13 +93,14 @@ pub(crate) struct MiddlewareInvocation { pub(crate) struct MiddlewareBinding { callbacks: Arc, resource_scope_id: String, - invocation: Arc, + invocation: Arc, + _scope: Arc, } struct InvocationState { closed: bool, original_request: MiddlewareRequest, - next: Option>, + next: Option, next_consumed: bool, response: Option, } @@ -91,17 +133,29 @@ pub(crate) enum MiddlewareCompletionBody { } impl MiddlewareInvocation { + pub(crate) fn settings(&self) -> Result { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + serde_json::to_value( + state + .original_request + .settings() + .and_then(gateway_core::settings::RequestSettings::execution_values), + ) + .map_err(|_| invalid()) + } pub(crate) fn new( context: &MiddlewareContext, request: MiddlewareRequest, - next: Box, - requests_authorized: bool, - capability_version: u32, + next: MiddlewareNext, + instance_id: String, ) -> Arc { Arc::new(Self { - requests_authorized, - capabilities_allowed: capability_version >= 2 - && context.mount() == gateway_core::engine::middleware::MiddlewareMount::Request + instance_id, + capabilities_allowed: context.mount() + == gateway_core::engine::middleware::MiddlewareMount::Request && context.operation() == Some(gateway_core::operation::OperationKind::Generate), transport: context.transport(), state: Mutex::new(InvocationState { @@ -115,33 +169,19 @@ impl MiddlewareInvocation { }) } - pub(crate) fn request_projection( - &self, - ) -> Result<(String, Vec, bool, Vec), PluginFault> { + pub(crate) fn request_parts(&self) -> Result<(String, Vec, Vec), PluginFault> { let state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let headers = if self.requests_authorized { - project_headers(state.original_request.headers(), HeaderDirection::Request)? - } else { - Vec::new() - }; - let body_visible = self.requests_authorized; - let body = if body_visible { - state.original_request.body().to_vec() - } else { - Vec::new() - }; Ok(( state.original_request.protocol().to_owned(), - headers, - body_visible, - body, + wire_headers(state.original_request.headers())?, + state.original_request.body().to_vec(), )) } - pub(crate) fn take_delegate(&self) -> Option<(Box, MiddlewareRequest)> { + pub(crate) fn take_delegate(&self) -> Option<(MiddlewareNext, MiddlewareRequest)> { let mut state = self .state .lock() @@ -186,8 +226,12 @@ impl MiddlewareInvocation { match method { NEXT_METHOD => self.call_next(params, payload).await, BODY_READ_METHOD => self.call_body_read(params, payload, maximum_payload).await, + BODY_FACTS_METHOD => self.call_body_facts(params, payload, maximum_payload).await, BODY_CLOSE_METHOD => self.call_body_close(params, payload).await, - _ => Err(denied()), + _ => Err(PluginFault::new( + ErrorCode::Unsupported, + "middleware method is unsupported", + )), } } @@ -211,29 +255,35 @@ impl MiddlewareInvocation { (next, state.original_request.clone()) }; let request = self.apply_next_request(original, request, payload)?; + if let Some(settings) = request.settings() { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.original_request = state + .original_request + .clone() + .with_settings(settings.clone()); + } let response = match next.run(request).await { Ok(response) => response, Err(error) => { + let fault = super::error::middleware(&error); self.record_downstream_error(error); - return Err(PluginFault::new( - ErrorCode::Fault, - "middleware downstream failed", - )); + return Err(fault); } }; let (protocol, status, headers, body, envelope) = response.into_parts(); validate_protocol(&protocol)?; validate_status(status)?; - validate_headers(&headers)?; + let response_headers = wire_headers(&headers)?; + let metadata = envelope + .as_ref() + .map(|envelope| Box::new(super::facts::metadata(envelope.metadata()))); let expected_framing = framing_for_response(self.transport, status); let response_id = uuid::Uuid::new_v4().to_string(); let body_handle = uuid::Uuid::new_v4().to_string(); let body = BodyResource::new(expected_framing, Arc::clone(&self.downstream_error), body); - let visible_headers = if self.requests_authorized { - project_headers(&headers, HeaderDirection::Response)? - } else { - Vec::new() - }; { let mut state = self .state @@ -242,7 +292,7 @@ impl MiddlewareInvocation { if state.closed || state.response.is_some() { drop(state); body.close_detached(); - return Err(denied()); + return Err(resource_unavailable()); } state.response = Some(DownstreamResponse { id: response_id.clone(), @@ -255,10 +305,11 @@ impl MiddlewareInvocation { }); } let result = MiddlewareNextResponse { + metadata, response: response_id, protocol, status, - headers: visible_headers, + headers: response_headers, body: Some(MiddlewareBodyHandle { handle: body_handle, framing: wire_framing(expected_framing), @@ -272,16 +323,29 @@ impl MiddlewareInvocation { fn apply_next_request( &self, - original: MiddlewareRequest, + mut original: MiddlewareRequest, request: MiddlewareNextRequest, payload: Vec, ) -> Result { + if let Some(settings) = request.settings { + let current = original.settings().ok_or_else(|| { + PluginFault::new( + ErrorCode::InvalidInput, + "execution settings are not available at this middleware boundary", + ) + })?; + let values = serde_json::from_value(settings).map_err(|_| invalid())?; + let updated = current + .replace_execution(&values, &self.instance_id) + .map_err(|_| invalid())?; + original = original.with_settings(updated); + } let (mut protocol, mut headers, original_body) = original.clone().into_parts(); let declaration = request .capabilities .map(|declaration| { - if !self.requests_authorized || !self.capabilities_allowed { - return Err(denied()); + if !self.capabilities_allowed { + return Err(invalid()); } if request.body != MiddlewareRequestBody::Replace { return Err(invalid()); @@ -312,33 +376,48 @@ impl MiddlewareInvocation { }) .transpose()?; if let Some(replacement) = request.protocol { - if !self.requests_authorized { - return Err(denied()); - } validate_protocol(&replacement)?; protocol = replacement; } if !request.header_mutations.is_empty() { - if !self.requests_authorized { - return Err(denied()); - } - apply_header_mutations( - &mut headers, - &request.header_mutations, - HeaderDirection::Request, - )?; + apply_header_mutations(&mut headers, &request.header_mutations)?; } let body = match request.body { MiddlewareRequestBody::Preserve if payload.is_empty() => original_body, MiddlewareRequestBody::Preserve => return Err(invalid()), - MiddlewareRequestBody::Replace if self.requests_authorized => Bytes::from(payload), - MiddlewareRequestBody::Replace => return Err(denied()), + MiddlewareRequestBody::Replace => Bytes::from(payload), }; original .replace_parts(protocol, headers, body, declaration) .map_err(|_| invalid()) } + async fn call_body_facts( + &self, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, + ) -> Result { + if !payload.is_empty() { + return Err(invalid()); + } + let request: MiddlewareBodyFacts = serde_json::from_value(params).map_err(|_| invalid())?; + let facts = self.body(&request.handle)?.facts(request.source_id).await?; + let present = facts.is_some(); + let payload = facts.unwrap_or_default(); + if payload.len() > maximum_payload { + return Err(PluginFault::new( + ErrorCode::Capacity, + "middleware facts exceed the payload limit", + )); + } + Ok(RpcReply { + result: serde_json::to_value(MiddlewareBodyFactsResult { present }) + .map_err(|_| invalid())?, + payload, + }) + } + async fn call_body_read( &self, params: serde_json::Value, @@ -348,9 +427,6 @@ impl MiddlewareInvocation { if !payload.is_empty() { return Err(invalid()); } - if !self.requests_authorized { - return Err(denied()); - } let request: MiddlewareBodyRead = serde_json::from_value(params).map_err(|_| invalid())?; let maximum = usize::try_from(request.maximum_bytes).map_err(|_| invalid())?; if maximum == 0 || maximum > maximum_payload { @@ -389,8 +465,8 @@ impl MiddlewareInvocation { params: serde_json::Value, payload: Vec, ) -> Result { - if !payload.is_empty() || !self.requests_authorized { - return Err(denied()); + if !payload.is_empty() { + return Err(invalid()); } let request: MiddlewareBodyClose = serde_json::from_value(params).map_err(|_| invalid())?; let body = self.body(&request.handle)?; @@ -406,9 +482,9 @@ impl MiddlewareInvocation { .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let response = state.response.as_ref().ok_or_else(denied)?; + let response = state.response.as_ref().ok_or_else(resource_unavailable)?; if state.closed || response.body_handle != handle { - return Err(denied()); + return Err(resource_unavailable()); } Ok(Arc::clone(&response.body)) } @@ -464,28 +540,15 @@ impl MiddlewareInvocation { }; if let Some(replacement) = response.protocol { validate_protocol(&replacement).map_err(|_| MiddlewareError::InvalidState)?; - if downstream.is_some() && replacement != protocol && !self.requests_authorized { - return Err(MiddlewareError::InvalidState); - } protocol = replacement; } if let Some(replacement) = response.status { validate_status(replacement).map_err(|_| MiddlewareError::InvalidState)?; - if downstream.is_some() && replacement != status && !self.requests_authorized { - return Err(MiddlewareError::InvalidState); - } status = replacement; } if !response.header_mutations.is_empty() { - if downstream.is_some() && !self.requests_authorized { - return Err(MiddlewareError::InvalidState); - } - apply_header_mutations( - &mut headers, - &response.header_mutations, - HeaderDirection::Response, - ) - .map_err(|_| MiddlewareError::InvalidState)?; + apply_header_mutations(&mut headers, &response.header_mutations) + .map_err(|_| MiddlewareError::InvalidState)?; } let body = match response.body { MiddlewareResponseBody::PassThrough { body } => { @@ -517,7 +580,7 @@ impl MiddlewareInvocation { || framing_for_response(self.transport, status), |(_, _, _, _, body, _)| body.expected_framing(), ); - if !self.requests_authorized || framing != expected { + if framing != expected { return Err(MiddlewareError::InvalidState); } MiddlewareCompletionBody::Stream { @@ -556,12 +619,14 @@ impl MiddlewareBinding { pub(super) fn new( callbacks: Arc, resource_scope_id: String, - invocation: Arc, + invocation: Arc, + scope: Arc, ) -> Self { Self { callbacks, resource_scope_id, invocation, + _scope: scope, } } } @@ -594,15 +659,14 @@ fn validate_status(status: u16) -> Result<(), PluginFault> { } const fn framing_for_response(transport: ClientTransport, status: u16) -> MiddlewareFraming { - if status < 200 || status >= 300 { - return MiddlewareFraming::RawBytes; - } match transport { - ClientTransport::HttpJson => MiddlewareFraming::JsonDocument, + // WebSocket 的失败也作为 JSON 消息交付,不使用 HTTP 错误正文的字节流边界。 + ClientTransport::WebSocket => MiddlewareFraming::JsonDocument, + _ if status < 200 || status >= 300 => MiddlewareFraming::RawBytes, ClientTransport::HttpSse => MiddlewareFraming::SseEvent, - ClientTransport::WebSocket + ClientTransport::HttpJson | ClientTransport::InternalProbe - | ClientTransport::InternalPlugin => MiddlewareFraming::RawBytes, + | ClientTransport::InternalPlugin => MiddlewareFraming::JsonDocument, } } @@ -622,11 +686,8 @@ pub(crate) const fn core_framing(framing: MiddlewareBodyFraming) -> MiddlewareFr } } -fn denied() -> PluginFault { - PluginFault::new( - ErrorCode::PermissionDenied, - "middleware resource is not authorized", - ) +fn resource_unavailable() -> PluginFault { + PluginFault::new(ErrorCode::Conflict, "middleware resource is unavailable") } fn invalid() -> PluginFault { diff --git a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs index 37d50b866..64fcdf2a0 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs @@ -2,7 +2,11 @@ mod accounts; mod admin; mod affinity; mod data; +pub(crate) mod error; +mod facts; mod http; +mod http_dispatch; +pub(crate) mod http_middleware; mod keys; mod log; mod middleware; @@ -10,7 +14,9 @@ mod model; pub(crate) mod private_state; mod resources; mod scope; +pub(crate) mod services; pub(crate) mod upstream; +pub(crate) mod websocket_middleware; use std::{ collections::BTreeMap, @@ -30,13 +36,15 @@ pub(crate) use accounts::PluginAccountPortSlot; pub(crate) use affinity::PluginAffinityPortSlot; pub(crate) use keys::PluginClientKeyPortSlot; pub(crate) use middleware::{ - MiddlewareBinding, MiddlewareBodyAuthority, MiddlewareCompletionBody, MiddlewareInvocation, + MiddlewareBinding, MiddlewareBodyAuthority, MiddlewareCallback, MiddlewareCompletionBody, + MiddlewareInvocation, }; pub(crate) use model::PluginModelPortSlot; pub(crate) use resources::PluginResourcePorts; -pub(crate) use scope::NetworkScope; +pub(crate) use scope::{CallbackScope, InvocationContext}; pub(crate) struct PluginCallbackPorts { + services: Arc, http: Arc, network: NetworkPolicy, accounts: Arc, @@ -48,8 +56,8 @@ pub(crate) struct PluginCallbackPorts { impl PluginCallbackPorts { pub(crate) fn new( + services: Arc, http: Arc, - network: NetworkPolicy, accounts: Arc, keys: Arc, models: Arc, @@ -57,8 +65,9 @@ impl PluginCallbackPorts { resources: Arc, ) -> Self { Self { + services, http, - network, + network: NetworkPolicy::unrestricted(), accounts, keys, models, @@ -69,6 +78,7 @@ impl PluginCallbackPorts { } pub(crate) struct PluginCallbacks { + services: Arc, accounts: Arc, resources: Arc, data: Arc, @@ -78,33 +88,60 @@ pub(crate) struct PluginCallbacks { models: Arc, private_state: Arc, http: Arc, - http_authorization: Arc, - scopes: Mutex>>, - pending_middleware: Mutex>>, + network: Arc, + scopes: Mutex>>, + pending_middleware: Mutex>>, calls: Mutex>>, maximum_payload: usize, } -struct HttpAuthorization { - network: NetworkPolicy, - authorized: bool, - upstream_authorized: bool, -} - struct CallResources { deadline: Instant, + operation_timeout: Duration, cancellation: CancellationToken, - scope: Arc, + scope: Arc, + http_resources: Arc, model_bindings: tokio::sync::Mutex< BTreeMap, >, state: Mutex, } +impl CallResources { + fn request_settings(&self) -> Option { + let middleware = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .middleware + .clone(); + middleware.and_then(|middleware| middleware.request_settings()) + } + + /// 建立受管流后,网络操作各自计时;连接空闲时间不消耗后续操作的预算。 + fn timeout(&self) -> Result { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.closed { + return Err(denied()); + } + if state.resource_stream { + return Ok(self.operation_timeout); + } + self.deadline + .checked_duration_since(Instant::now()) + .filter(|timeout| !timeout.is_zero()) + .ok_or_else(|| PluginFault::new(ErrorCode::Timeout, "callback deadline elapsed")) + } +} + #[derive(Default)] struct CallState { closed: bool, - middleware: Option>, + resource_stream: bool, + middleware: Option>, streams: BTreeMap>, model_streams: BTreeMap>, } @@ -132,35 +169,24 @@ impl PluginCallbacks { private_state: Arc, ports: PluginCallbackPorts, ) -> Result { - let grants = &instance.grants; Ok(Self { + services: ports.services, resources: Arc::new(resources::PluginResources::new(instance, ports.resources)), data: Arc::new(data::PluginData::new( ports.accounts.clone(), ports.keys.clone(), - grants, )), - accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts, grants)), + accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)), keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)), - affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity, grants)), + affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)), log: Arc::new( log::PluginLog::new(manifest, log_slots) .map_err(|_| AdminError::invalid("插件清单身份不合法"))?, ), - models: Arc::new(model::PluginModels::new( - ports.models, - grants, - maximum_payload, - )), + models: Arc::new(model::PluginModels::new(ports.models, maximum_payload)), private_state, http: ports.http, - http_authorization: Arc::new(HttpAuthorization { - network: ports.network, - authorized: grants.iter().any(|grant| grant.permission == "network"), - upstream_authorized: grants - .iter() - .any(|grant| grant.permission == "upstream_connections"), - }), + network: Arc::new(ports.network), scopes: Mutex::new(BTreeMap::new()), pending_middleware: Mutex::new(BTreeMap::new()), calls: Mutex::new(BTreeMap::new()), @@ -171,7 +197,8 @@ impl PluginCallbacks { pub(crate) fn bind_middleware( self: &Arc, resource_scope_id: String, - invocation: Arc, + invocation: Arc, + scope: Arc, ) -> Result { let mut pending = self .pending_middleware @@ -187,10 +214,11 @@ impl PluginCallbacks { Arc::clone(self), resource_scope_id, invocation, + scope, )) } - fn unbind_middleware(&self, resource_scope_id: &str, invocation: &Arc) { + fn unbind_middleware(&self, resource_scope_id: &str, invocation: &Arc) { let mut pending = self .pending_middleware .lock() @@ -206,8 +234,11 @@ impl PluginCallbacks { pub(crate) fn prepare_data_plane( &self, context: &CallContext, - effects: Arc, - ) -> Result, PluginFault> { + effects: Option>, + extensions: gateway_core::engine::extensions::ExtensionCallScope, + plan: Option, + cancellation: CancellationToken, + ) -> Result, PluginFault> { if !matches!( context.stage, gateway_plugin_sdk::Stage::Request @@ -217,10 +248,24 @@ impl PluginCallbacks { ) { return Err(denied()); } - self.register_scope( - context, - NetworkScope::for_call(context).with_execution_effects(effects), - ) + let mut scope = CallbackScope::for_call(context); + if let Some(effects) = effects { + scope = scope.with_execution_effects(effects); + } + scope.extension_scope = extensions; + scope.origin = plan.map(|plan| InvocationContext { + request_id: context + .request_id + .clone() + .unwrap_or_else(|| context.resource_scope_id.clone()), + call_id: context + .request_id + .clone() + .unwrap_or_else(|| context.resource_scope_id.clone()), + cancellation, + plan, + }); + self.register_scope(context, scope) } pub(crate) fn prepare_upstream( @@ -228,15 +273,14 @@ impl PluginCallbacks { context: &CallContext, managed: Arc, extension_scope: gateway_core::engine::extensions::ExtensionCallScope, - ) -> Result, PluginFault> { + ) -> Result, PluginFault> { if context.stage != gateway_plugin_sdk::Stage::Upstream - || !self.http_authorization.upstream_authorized || context.account_id.as_deref() != Some(managed.account.account_id().as_str()) || context.credential_revision != Some(managed.account.credential_revision().get()) { return Err(denied()); } - let mut scope = NetworkScope::new(context, managed.account.outbound_proxy().cloned()) + let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned()) .with_upstream(managed); scope.extension_scope = extension_scope; self.register_scope(context, scope) @@ -246,7 +290,7 @@ impl PluginCallbacks { &self, context: &CallContext, proxy: Option, - ) -> Result, PluginFault> { + ) -> Result, PluginFault> { if !matches!(context.stage, gateway_plugin_sdk::Stage::Management) { return Err(denied()); } @@ -256,7 +300,7 @@ impl PluginCallbacks { pub(crate) fn prepare_command_line( &self, context: &CallContext, - ) -> Result, PluginFault> { + ) -> Result, PluginFault> { if context.stage != gateway_plugin_sdk::Stage::CommandLine { return Err(denied()); } @@ -266,7 +310,7 @@ impl PluginCallbacks { pub(crate) fn prepare_frontend_authentication( &self, context: &CallContext, - ) -> Result, PluginFault> { + ) -> Result, PluginFault> { if context.stage != gateway_plugin_sdk::Stage::Authentication || context.account_id.is_some() || context.credential_revision.is_some() @@ -280,7 +324,7 @@ impl PluginCallbacks { pub(crate) async fn save_command_account( &self, context: &CallContext, - scope: &NetworkScope, + scope: &CallbackScope, request: gateway_plugin_sdk::call::host::AuthSaveRequest, ) -> Result { if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) { @@ -293,25 +337,41 @@ impl PluginCallbacks { &self, context: &CallContext, proxy: Option, - ) -> Result, PluginFault> { - self.register_scope(context, NetworkScope::new(context, proxy)) + ) -> Result, PluginFault> { + self.register_scope(context, CallbackScope::new(context, proxy)) } pub(crate) fn prepare_observation( &self, context: &CallContext, extension_scope: gateway_core::engine::extensions::ExtensionCallScope, - ) -> Result, PluginFault> { - let mut scope = NetworkScope::for_call(context); + ) -> Result, PluginFault> { + let mut scope = CallbackScope::for_call(context); scope.extension_scope = extension_scope; self.register_scope(context, scope) } + pub(crate) fn bind_invocation( + self: &Arc, + call: &CallContext, + origin: Option, + extensions: gateway_core::engine::extensions::ExtensionCallScope, + invocation: Arc, + ) -> Result { + let mut scope = CallbackScope::for_call(call); + scope.extension_scope = extensions; + scope.origin = origin; + let scope = self + .register_scope(call, scope) + .map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?; + self.bind_middleware(call.resource_scope_id.clone(), invocation, scope) + } + fn register_scope( &self, context: &CallContext, - scope: NetworkScope, - ) -> Result, PluginFault> { + scope: CallbackScope, + ) -> Result, PluginFault> { let scope = Arc::new(scope); let mut scopes = self .scopes @@ -338,7 +398,7 @@ impl CallbackHandler for PluginCallbacks { .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&context.resource_scope_id) .and_then(Weak::upgrade) - .unwrap_or_else(|| Arc::new(NetworkScope::for_call(context))); + .unwrap_or_else(|| Arc::new(CallbackScope::for_call(context))); let middleware = self .pending_middleware .lock() @@ -351,8 +411,13 @@ impl CallbackHandler for PluginCallbacks { context.call_id, Arc::new(CallResources { deadline: Instant::now() + Duration::from_millis(context.timeout_ms), + operation_timeout: Duration::from_millis(context.timeout_ms), cancellation: CancellationToken::new(), scope, + http_resources: middleware + .as_ref() + .and_then(|middleware| middleware.http_resources()) + .unwrap_or_default(), model_bindings: tokio::sync::Mutex::new(BTreeMap::new()), state: Mutex::new(CallState { middleware, @@ -362,6 +427,20 @@ impl CallbackHandler for PluginCallbacks { ); } + fn streaming(&self, context: &CallContext) { + if let Some(call) = self + .calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&context.call_id) + { + call.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .resource_stream = context.resource_stream; + } + } + fn finish(&self, context: &CallContext) { if let Some(call) = self .calls @@ -404,7 +483,7 @@ impl CallbackHandler for PluginCallbacks { .get(&context.call_id) .cloned(); let http = self.http.clone(); - let authorization = self.http_authorization.clone(); + let network = self.network.clone(); let maximum_payload = self.maximum_payload; let log = self.log.clone(); let private_state = self.private_state.clone(); @@ -414,6 +493,7 @@ impl CallbackHandler for PluginCallbacks { let keys = self.keys.clone(); let models = self.models.clone(); let affinity = self.affinity.clone(); + let services = self.services.clone(); Box::pin(async move { let call = call.ok_or_else(denied)?; let scope = &call.scope; @@ -422,18 +502,13 @@ impl CallbackHandler for PluginCallbacks { .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.closed || Instant::now() >= call.deadline { + if state.closed || (!state.resource_stream && Instant::now() >= call.deadline) { return Err(denied()); } if method == "host.log" { return log.record(&context, params, &payload); } - let middleware = if matches!( - method.as_str(), - gateway_plugin_sdk::call::middleware::NEXT_METHOD - | gateway_plugin_sdk::call::middleware::BODY_READ_METHOD - | gateway_plugin_sdk::call::middleware::BODY_CLOSE_METHOD - ) { + let middleware = if method.starts_with("host.middleware.") { Some(state.middleware.clone().ok_or_else(denied)?) } else { None @@ -444,9 +519,28 @@ impl CallbackHandler for PluginCallbacks { ); (middleware, is_private_state) }; + if matches!( + method.as_str(), + gateway_plugin_sdk::call::middleware::http::DISPATCH_METHOD + | gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD + | gateway_plugin_sdk::call::middleware::http::BODY_CLOSE_METHOD + | gateway_plugin_sdk::call::middleware::http::BODY_CREATE_METHOD + | gateway_plugin_sdk::call::middleware::http::BODY_WRITE_METHOD + ) { + return tokio::select! { + biased; + () = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")), + result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result, + }; + } if let Some(middleware) = middleware { return middleware - .call(&method, params, payload, maximum_payload) + .invoke(method, params, payload, maximum_payload) + .await; + } + if method == gateway_plugin_sdk::call::services::CALL_METHOD { + return services + .call(&context, scope, call.cancellation.clone(), params, &payload) .await; } if is_private_state { @@ -494,7 +588,7 @@ impl CallbackHandler for PluginCallbacks { | gateway_plugin_sdk::call::data::QUOTA_GET | gateway_plugin_sdk::call::data::QUOTA_REFRESH ) { - return data.call(&context, &method, params, &payload).await; + return data.call(&method, params, &payload).await; } if matches!( method.as_str(), @@ -506,22 +600,14 @@ impl CallbackHandler for PluginCallbacks { } let http = http::HttpCallbacks { client: &http, - authorization: &authorization, + network: &network, scope, call: &call, maximum_payload, }; if method.starts_with("host.upstream.") { - if !authorization.upstream_authorized - || context.stage != gateway_plugin_sdk::Stage::Upstream - { - return Err(denied()); - } return upstream::dispatch(&http, &method, params, payload).await; } - if !authorization.authorized { - return Err(denied()); - } http.dispatch(&method, params, payload).await }) } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/model.rs b/backend/crates/gateway-plugin/runtime/src/callback/model.rs index 03fef77da..8ead173b7 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/model.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/model.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, OnceLock, Weak}; use bytes::Bytes; -use gateway_admin::model::{AdminError, plugins::instances::PluginPermissionGrant}; +use gateway_admin::model::AdminError; use gateway_core::{ account::ProviderAccountId, engine::{ @@ -16,8 +16,7 @@ use gateway_core::{ NestedModelExecutionRequest, }, }, - error::{GatewayError, GatewayErrorKind}, - event::{ContentKind, FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent}, + event::ProviderEvent, identity::ProviderKind, operation::{ GenerateRequest, ImageRequest, ImageRequestKind, Operation, ProtocolPayload, @@ -28,16 +27,10 @@ use gateway_core::{ }; use gateway_plugin_sdk::{ CallContext, ErrorCode, PluginFault, - call::{ - host::{ - ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest, - ModelListResult, ModelOperation, ModelStreamCloseRequest, ModelStreamReadRequest, - ModelStreamReadResult, ModelStreamResult, - }, - model::{ - CanonicalEvent, ContentKind as WireContentKind, ExecutionEvent, - FinishReason as WireFinishReason, Usage as WireUsage, WireEvent, WirePayload, - }, + call::host::{ + ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest, + ModelListResult, ModelOperation, ModelStreamCloseRequest, ModelStreamReadRequest, + ModelStreamReadResult, ModelStreamResult, }, }; @@ -70,19 +63,13 @@ impl PluginModelPortSlot { pub(super) struct PluginModels { slot: Arc, - authorized: bool, maximum_payload: usize, } impl PluginModels { - pub(super) fn new( - slot: Arc, - grants: &[PluginPermissionGrant], - maximum_payload: usize, - ) -> Self { + pub(super) fn new(slot: Arc, maximum_payload: usize) -> Self { Self { slot, - authorized: grants.iter().any(|grant| grant.permission == "models"), maximum_payload, } } @@ -95,9 +82,6 @@ impl PluginModels { params: serde_json::Value, payload: Vec, ) -> Result { - if !self.authorized { - return Err(denied()); - } match method { "host.models.list" => { if !payload.is_empty() { @@ -114,21 +98,19 @@ impl PluginModels { let port = self.slot.upgrade()?; let bound = port .bind(BoundModelExecutionBinding { + settings: call.request_settings(), client_key_id: ClientApiKeyId::new(request.client_key_id) .map_err(|_| invalid())?, initiating_plugin_instance_id: context.instance_id.clone(), - timeout: call - .deadline - .saturating_duration_since(tokio::time::Instant::now()), cancellation: call.cancellation.child_token(), extension_scope: call.scope.extension_scope.clone(), }) .await - .map_err(gateway_fault)?; + .map_err(super::error::gateway)?; let models = port .models(bound, request.protocol, request.client_version) .await - .map_err(gateway_fault)?; + .map_err(super::error::gateway)?; encode_result(ModelListResult { models: models .into_iter() @@ -144,24 +126,14 @@ impl PluginModels { } let port = self.slot.upgrade()?; let stream = method == "host.model.execute_stream"; - let started = if matches!( - context.stage, - gateway_plugin_sdk::Stage::Management - | gateway_plugin_sdk::Stage::CommandLine - | gateway_plugin_sdk::Stage::Authentication - | gateway_plugin_sdk::Stage::Observation - ) { - let client_key_id = request.client_key_id.take().ok_or_else(invalid)?; + let started = if let Some(client_key_id) = request.client_key_id.take() { let bound = self.binding(&port, context, call, client_key_id).await?; port.start_bound(self.bound_request(request, payload, stream, bound)?) .await - .map_err(gateway_fault)? + .map_err(super::error::gateway)? } else { - if request.client_key_id.is_some() { - return Err(invalid()); - } let request = self.request(context, request, payload, stream)?; - port.start(request).await.map_err(gateway_fault)? + port.start(request).await.map_err(super::error::gateway)? }; if method == "host.model.execute" { execute_buffered(started, self.maximum_payload).await @@ -257,11 +229,15 @@ impl PluginModels { client_key_id: String, ) -> Result { let key = ClientApiKeyId::new(client_key_id.clone()).map_err(|_| invalid())?; + let settings = call.request_settings(); let mut bindings = call.model_bindings.lock().await; - if let Some(bound) = bindings.get(&client_key_id) { + if let Some(bound) = bindings + .get(&client_key_id) + .filter(|bound| bound.request_settings() == settings.as_ref()) + { return Ok(bound.clone()); } - if bindings.len() >= MAX_MODEL_STREAMS_PER_CALL { + if !bindings.contains_key(&client_key_id) && bindings.len() >= MAX_MODEL_STREAMS_PER_CALL { return Err(PluginFault::new( ErrorCode::Capacity, "model identity capacity is exhausted", @@ -269,18 +245,16 @@ impl PluginModels { } let bound = port .bind(BoundModelExecutionBinding { + settings, client_key_id: key, initiating_plugin_instance_id: context.instance_id.clone(), - timeout: call - .deadline - .saturating_duration_since(tokio::time::Instant::now()), cancellation: call.cancellation.child_token(), extension_scope: call.scope.extension_scope.clone(), }) .await - .map_err(gateway_fault)?; + .map_err(super::error::gateway)?; // 调用持有身份直到 RPC 结束,流式执行不会因单次 callback 返回而提前取消; - // 同一 Key 复用 Core 调用图,不能通过重复 bind 重置深度与并发限制。 + // 同一 Key 复用身份快照,每次执行由 Core 创建独立调用图。 bindings.insert(client_key_id, bound.clone()); Ok(bound) } @@ -292,11 +266,14 @@ impl PluginModels { body: Vec, stream: bool, ) -> Result { - let parent_request_id = context - .request_id - .as_ref() - .ok_or_else(denied) - .and_then(|request_id| ModelRequestId::new(request_id.clone()).map_err(|_| denied()))?; + let parent_request_id = + context + .request_id + .as_ref() + .ok_or_else(invalid) + .and_then(|request_id| { + ModelRequestId::new(request_id.clone()).map_err(|_| invalid()) + })?; let parent_account = context .account_id .as_ref() @@ -340,9 +317,6 @@ impl PluginModels { body: Vec, stream: bool, ) -> Result { - if !self.authorized { - return Err(denied()); - } let provider = request .provider .map(ProviderKind::new) @@ -442,9 +416,7 @@ async fn execute_buffered( let events = match started.session.collect_uncommitted().await { Ok(events) => events, Err(error) => { - let fault = gateway_fault(gateway_core::engine::execution::gateway_error_from_engine( - &error, - )); + let fault = super::error::engine(&error); started.session.detach_finalize().await; return Err(fault); } @@ -464,9 +436,7 @@ async fn execute_buffered( )); } if let Err(error) = started.session.commit_downstream(Some(200)).await { - let fault = gateway_fault(gateway_core::engine::execution::gateway_error_from_engine( - &error, - )); + let fault = super::error::engine(&error); started.session.detach_finalize().await; return Err(fault); } @@ -557,9 +527,7 @@ impl ModelStream { .commit_downstream(Some(200)) .await; if let Err(error) = result { - let fault = gateway_fault( - gateway_core::engine::execution::gateway_error_from_engine(&error), - ); + let fault = super::error::engine(&error); let session = state.session.take(); drop(state); if let Some(session) = session { @@ -616,8 +584,7 @@ impl ModelStream { } }; if events == 0 { - // 首次未提交批次即使只含 Core 内部事实,也必须释放交付屏障; - // 已提交批次没有待交付状态,过滤后可直接继续读取下一帧。 + // 空批次仍需释放待交付屏障,已经提交的空批次直接继续拉取。 if commit { let result = state .session @@ -681,9 +648,7 @@ impl ModelStream { }); } Err(error) => { - let fault = gateway_fault( - gateway_core::engine::execution::gateway_error_from_engine(&error), - ); + let fault = super::error::engine(&error); let session = state.session.take(); drop(state); if let Some(session) = session { @@ -714,7 +679,7 @@ impl ModelStream { fn encode_events(events: Vec) -> Result<(Vec, u32), PluginFault> { let events = events .into_iter() - .filter_map(|event| project_event(event).transpose()) + .map(super::facts::event) .collect::, _>>()?; let count = u32::try_from(events.len()).map_err(|_| invalid())?; if events.is_empty() { @@ -726,144 +691,6 @@ fn encode_events(events: Vec) -> Result<(Vec, u32), PluginFau Ok((payload, count)) } -fn project_event(event: ProviderEvent) -> Result, PluginFault> { - let (facts, wire) = event.into_parts(); - let facts = facts - .into_iter() - .filter_map(project_fact) - .collect::, _>>()?; - let wire = wire.map(project_wire).transpose()?; - if facts.is_empty() && wire.is_none() { - return Ok(None); - } - Ok(Some(ExecutionEvent { facts, wire })) -} - -fn project_fact(event: GatewayEvent) -> Option> { - Some(Ok(match event { - GatewayEvent::Started(meta) => CanonicalEvent::Started { - id: meta.response_id().to_owned(), - model: meta.model().map(str::to_owned), - }, - GatewayEvent::ContentAdded(item) => CanonicalEvent::ContentAdded { - index: item.index(), - kind: match item.kind() { - ContentKind::Text => WireContentKind::Text, - ContentKind::Reasoning => WireContentKind::Reasoning, - ContentKind::ToolCall => WireContentKind::ToolCall, - ContentKind::Image => WireContentKind::Image, - ContentKind::Audio => WireContentKind::Audio, - _ => { - return Some(Err(PluginFault::new( - ErrorCode::Unsupported, - "unknown content kind", - ))); - } - }, - }, - GatewayEvent::TextDelta(delta) => CanonicalEvent::TextDelta { - index: delta.content_index, - text: delta.text, - }, - GatewayEvent::ReasoningDelta(delta) => CanonicalEvent::ReasoningDelta { - index: delta.content_index, - text: delta.text, - }, - GatewayEvent::ToolCallDelta(delta) => CanonicalEvent::ToolCallDelta { - index: delta.content_index, - id: delta.call_id, - name: delta.name, - arguments: delta.arguments_delta, - }, - GatewayEvent::Usage(usage) => CanonicalEvent::Usage { - usage: WireUsage { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - cached_tokens: usage.cached_tokens, - cache_write_tokens: usage.cache_write_tokens, - reasoning_tokens: usage.reasoning_tokens, - image_input_tokens: usage.image_input_tokens, - image_output_tokens: usage.image_output_tokens, - total_tokens: usage.total_tokens, - }, - }, - // 费用已经由子请求的 Core 结算;不把它重新作为父插件可上报的用量输入。 - GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => return None, - GatewayEvent::Completed(meta) => CanonicalEvent::Completed { - id: meta.response_id().to_owned(), - model: meta.model().map(str::to_owned), - reason: match meta.finish_reason().unwrap_or(FinishReason::Other) { - FinishReason::Stop => WireFinishReason::Stop, - FinishReason::Length => WireFinishReason::Length, - FinishReason::ToolCall => WireFinishReason::ToolCall, - FinishReason::ContentFilter => WireFinishReason::ContentFilter, - FinishReason::Other => WireFinishReason::Other, - _ => WireFinishReason::Other, - }, - }, - _ => { - return Some(Err(PluginFault::new( - ErrorCode::Unsupported, - "unknown model event", - ))); - } - })) -} - -fn project_wire(wire: ProtocolWireEvent) -> Result { - let protocol = wire.protocol().to_owned(); - let payload = if let Some(body) = wire.raw_http_body_bytes() { - WirePayload::RawBody { - body: body.to_vec(), - } - } else if let Some(body) = wire.raw_json_body() { - WirePayload::RawJson { - body: body.to_vec(), - } - } else if wire.has_json_data() { - WirePayload::Json { - event: wire.event_type().map(str::to_owned), - data: wire.data().clone(), - id: wire.sse_id().map(str::to_owned), - retry: wire.sse_retry(), - raw_sse: wire.raw_sse_frame().map(|frame| frame.to_vec()), - } - } else if let Some(frame) = wire.raw_sse_frame() { - WirePayload::RawSse { - frame: frame.to_vec(), - } - } else { - return Err(PluginFault::new( - ErrorCode::Fault, - "model wire event has no payload", - )); - }; - Ok(WireEvent { protocol, payload }) -} - -pub(super) fn gateway_fault(error: GatewayError) -> PluginFault { - let code = match error.kind() { - GatewayErrorKind::InvalidRequest | GatewayErrorKind::MessageTooBig => { - ErrorCode::InvalidInput - } - GatewayErrorKind::Unsupported | GatewayErrorKind::ModelNotFound => ErrorCode::Unsupported, - GatewayErrorKind::Unauthorized => ErrorCode::PermissionDenied, - GatewayErrorKind::PolicyDenied => ErrorCode::Rejected, - GatewayErrorKind::AccountCapacityUnavailable - | GatewayErrorKind::ConcurrencyQueueFull - | GatewayErrorKind::ConcurrencyQueueTimeout - | GatewayErrorKind::RateLimited - | GatewayErrorKind::NoAvailableProvider => ErrorCode::Capacity, - GatewayErrorKind::Timeout => ErrorCode::Timeout, - GatewayErrorKind::Cancelled => ErrorCode::Cancelled, - GatewayErrorKind::UpstreamUnavailable - | GatewayErrorKind::ProviderInfrastructureUnavailable => ErrorCode::Upstream, - GatewayErrorKind::Internal => ErrorCode::Fault, - _ => ErrorCode::Fault, - }; - PluginFault::new(code, "nested model execution failed") -} - fn encode_result(value: impl serde::Serialize) -> Result { Ok(RpcReply { result: serde_json::to_value(value).map_err(|_| invalid())?, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/scope.rs b/backend/crates/gateway-plugin/runtime/src/callback/scope.rs index 56c742e68..5c8f7c4ff 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/scope.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/scope.rs @@ -5,8 +5,18 @@ use gateway_core::{ }; use gateway_plugin_sdk::{CallContext, Stage}; -/// 一次回调的账号、网络与嵌套模型授权事实,由操作持有到完成。 -pub(crate) struct NetworkScope { +/// 所有中间件共享的父调用身份;不混入协议操作或服务参数。 +#[derive(Clone)] +pub(crate) struct InvocationContext { + pub request_id: String, + pub call_id: String, + pub cancellation: gateway_core::lifecycle::CancellationToken, + pub plan: gateway_core::engine::middleware::FrozenMiddlewarePlan, +} + +/// 一次回调的调用链、账号与网络事实,由操作持有到完成。 +pub(crate) struct CallbackScope { + pub(super) origin: Option, stage: Stage, account_id: Option, credential_revision: Option, @@ -16,9 +26,39 @@ pub(crate) struct NetworkScope { pub(super) upstream: Option>, } -impl NetworkScope { +impl CallbackScope { + pub(super) fn observe_http_dispatch(&self) { + // 子路由及其插件可能执行模型或提交事务,父执行不能再按 Provider 的 not_sent 透明重放。 + if let Some(effects) = &self.execution_effects { + effects.observe(); + } + } + + pub(super) fn child_extensions( + &self, + instance_id: &str, + ) -> Result + { + // 上游适配器在进入回调前已登记当前实例;主动子调用沿用该集合。 + if self.extension_scope.contains(instance_id) { + return Ok(self.extension_scope.clone()); + } + if self.extension_scope.len() + >= gateway_core::engine::extensions::ExtensionCallScope::MAXIMUM_DEPTH + { + return Err(gateway_plugin_sdk::PluginFault::new( + gateway_plugin_sdk::ErrorCode::Capacity, + "child call depth exceeded", + )); + } + self.extension_scope + .extending(instance_id.to_owned()) + .ok_or_else(super::invalid) + } + pub(super) fn new(context: &CallContext, proxy: Option) -> Self { Self { + origin: None, stage: context.stage, account_id: context.account_id.clone(), credential_revision: context.credential_revision, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/services/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/services/mod.rs new file mode 100644 index 000000000..73d6a67a0 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/services/mod.rs @@ -0,0 +1,133 @@ +//! 通用公开服务回调;操作目录与类型校验由业务服务登记。 + +use super::{CallbackScope, MiddlewareCallback, invalid}; +use crate::RpcReply; +use futures::future::BoxFuture; +use gateway_admin::{model::AdminError, service::Registry}; +use gateway_core::middleware::service as core; +use gateway_plugin_sdk::{ + CallContext, PluginFault, + call::{middleware::NEXT_METHOD, services as wire}, +}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; + +#[derive(Default)] +pub(crate) struct ServicePorts { + registry: OnceLock>, + http: OnceLock>, +} + +impl ServicePorts { + pub(crate) fn bind_http( + &self, + dispatcher: &Arc, + ) -> Result<(), AdminError> { + self.http + .set(Arc::downgrade(dispatcher)) + .map_err(|_| AdminError::conflict("HTTP 分派端口已经绑定")) + } + + pub(super) fn http( + &self, + ) -> Result, PluginFault> { + self.http.get().and_then(Weak::upgrade).ok_or_else(|| { + PluginFault::new( + gateway_plugin_sdk::ErrorCode::Fault, + "HTTP dispatcher is unavailable", + ) + }) + } + + pub(crate) fn bind(&self, registry: &Arc) -> Result<(), AdminError> { + self.registry + .set(Arc::downgrade(registry)) + .map_err(|_| AdminError::conflict("公开服务已经绑定")) + } + + pub(super) async fn call( + &self, + context: &CallContext, + scope: &CallbackScope, + cancellation: gateway_core::lifecycle::CancellationToken, + params: serde_json::Value, + payload: &[u8], + ) -> Result { + if !payload.is_empty() { + return Err(invalid()); + } + let request: wire::Request = serde_json::from_value(params).map_err(|_| invalid())?; + let registry = self.registry.get().and_then(Weak::upgrade).ok_or_else(|| { + gateway_plugin_sdk::PluginFault::new( + gateway_plugin_sdk::ErrorCode::Fault, + "公开服务尚未绑定", + ) + })?; + let call = registry.call(&request.operation, request.input); + let origin = scope.origin.as_ref(); + let parent = gateway_admin::service::Origin { + request_id: origin.map_or_else( + || { + context + .request_id + .clone() + .unwrap_or_else(|| context.resource_scope_id.clone()) + }, + |origin| origin.request_id.clone(), + ), + call_id: origin.map_or_else( + || format!("{}:{}", context.resource_scope_id, context.call_id), + |origin| origin.call_id.clone(), + ), + cancellation, + extensions: scope.child_extensions(&context.instance_id)?, + plan: origin.map_or(gateway_admin::service::Plan::Current, |origin| { + gateway_admin::service::Plan::Frozen(Some(origin.plan.clone())) + }), + }; + let result = gateway_admin::service::scope(parent, call).await; + encode(result) + } +} + +pub(crate) struct Invocation(Mutex>); + +impl Invocation { + pub(crate) fn new(next: core::Next) -> Arc { + Arc::new(Self(Mutex::new(Some(next)))) + } +} + +impl MiddlewareCallback for Invocation { + fn invoke( + self: Arc, + method: String, + params: serde_json::Value, + payload: Vec, + _maximum: usize, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + if method != NEXT_METHOD || !payload.is_empty() { + return Err(invalid()); + } + let next = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(invalid)?; + encode(next.run(params).await) + }) + } +} + +pub(crate) fn encode(result: Result) -> Result { + let result: wire::Response = result.map_err(|error| wire::ServiceError { + kind: error.kind, + message: error.message, + details: error.details, + }); + Ok(RpcReply { + result: serde_json::to_value(result).map_err(|_| invalid())?, + payload: Vec::new(), + }) +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs index cf5162791..d39c5fd8c 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs @@ -22,10 +22,7 @@ use gateway_plugin_sdk::{ }; use super::{denied, http::HttpCallbacks, invalid}; -use crate::{ - RpcReply, - adapter::upstream_adapter::target::{UpstreamTarget, protected_header}, -}; +use crate::{RpcReply, adapter::upstream_adapter::target::UpstreamTarget}; pub(crate) use sessions::{ConnectionOwner, ConnectionPool}; @@ -58,7 +55,7 @@ pub(crate) struct ManagedUpstream { pub(crate) send_state: Arc, pub(crate) effects: Option>, target: Arc, - socket: tokio::sync::Mutex>, + socket: tokio::sync::Mutex)>>, pool: Arc, owner: ConnectionOwner, websocket_allowed: bool, @@ -74,7 +71,10 @@ impl ManagedUpstream { connection: Option<&str>, websocket_allowed: bool, ) -> Result, PluginFault> { - let socket = connection.map(|id| pool.take(id, &owner)).transpose()?; + let socket = connection + .map(|id| pool.take(id, &owner)) + .transpose()? + .map(|(url, socket)| (url, Arc::new(socket))); Ok(Arc::new(Self { account, target, @@ -88,8 +88,43 @@ impl ManagedUpstream { } pub(crate) async fn retain_connection(&self) -> Result { - let socket = self.socket.lock().await.take().ok_or_else(denied)?; - self.pool.put(self.owner.clone(), socket) + let (url, socket) = self.socket.lock().await.take().ok_or_else(denied)?; + // 旧调用仍在收发时不能把同一连接交给下一次执行。 + let socket = match Arc::try_unwrap(socket) { + Ok(socket) if !socket.is_closed() => socket, + Ok(_) => return Err(denied()), + Err(socket) => { + socket.close().await; + return Err(PluginFault::new( + ErrorCode::Conflict, + "upstream connection is still in use", + )); + } + }; + self.pool.put(self.owner.clone(), (url, socket)) + } + + async fn connection(&self) -> Result, PluginFault> { + self.socket + .lock() + .await + .as_ref() + .map(|(_, socket)| Arc::clone(socket)) + .ok_or_else(denied) + } + + async fn remove_closed_connection(&self, connection: &Arc) { + if !connection.is_closed() { + return; + } + let mut socket = self.socket.lock().await; + // 关闭后的读取结果可能晚于下一次 open,不能移除新连接。 + if socket + .as_ref() + .is_some_and(|(_, current)| Arc::ptr_eq(current, connection)) + { + socket.take(); + } } fn request( @@ -98,13 +133,8 @@ impl ManagedUpstream { body: Vec, ) -> Result<(HttpRequest, UpstreamPathPurpose), PluginFault> { let (target, purpose) = self.target.resolve(&request.path, &request.query)?; - if request.headers.len() > 128 - || request - .headers - .iter() - .any(|(name, _)| protected_header(name)) - { - return Err(denied()); + if request.headers.len() > 128 { + return Err(invalid()); } let authorization = self.account.authorization().map_err(|_| { PluginFault::new( @@ -112,22 +142,26 @@ impl ManagedUpstream { "selected account authentication is unavailable", ) })?; - if request.headers.iter().any(|(name, _)| { - authorization - .iter() - .any(|header| name.eq_ignore_ascii_case(header.name())) - }) { - return Err(denied()); - } - let headers = request - .headers + // 账号设置先构成基线;插件同名输入替换默认值,多值头保持原顺序。 + let mut headers: Vec<_> = authorization .into_iter() - .map(|(name, value)| (name, value.into_bytes())) - .chain(authorization.into_iter().map(|header| { + .filter(|header| { + !request + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(header.name())) + }) + .map(|header| { let (name, value) = header.into_parts(); (name, value.to_vec()) - })) + }) .collect(); + headers.extend( + request + .headers + .into_iter() + .map(|(name, value)| (name, value.into_bytes())), + ); Ok(( HttpRequest { method: request.method, @@ -148,18 +182,15 @@ pub(super) async fn dispatch( ) -> Result { let HttpCallbacks { client, - authorization, + network, scope, call, maximum_payload, } = *http; let managed = scope.upstream.as_ref().ok_or_else(denied)?; let timeout = || { - call.deadline - .checked_duration_since(tokio::time::Instant::now()) - .filter(|remaining| !remaining.is_zero()) + call.timeout() .map(|remaining| remaining.min(Duration::from_secs(120))) - .ok_or_else(|| PluginFault::new(ErrorCode::Timeout, "upstream deadline elapsed")) }; match method { "host.upstream.http.do" | "host.upstream.http.do_stream" => { @@ -199,7 +230,10 @@ pub(super) async fn dispatch( return Err(denied()); } let mut socket = managed.socket.try_lock().map_err(|_| denied())?; - if let Some((url, _)) = socket.as_ref() { + if let Some((url, _)) = socket + .as_ref() + .filter(|(_, connection)| !connection.is_closed()) + { if *url != request.url { return Err(denied()); } @@ -214,7 +248,7 @@ pub(super) async fn dispatch( .open_websocket( request, managed.account.outbound_proxy(), - &authorization.network, + network, timeout()?, ) .await; @@ -245,7 +279,7 @@ pub(super) async fn dispatch( } } if let Some(connection) = response.connection { - *socket = Some((url, connection)); + *socket = Some((url, Arc::new(connection))); } Ok(RpcReply { result: serde_json::to_value(wire::HttpResponse { @@ -266,8 +300,7 @@ pub(super) async fn dispatch( } adapter::WebSocketMessageKind::Binary => WebSocketMessage::Binary(payload.into()), }; - let mut socket = managed.socket.try_lock().map_err(|_| denied())?; - let (_, socket) = socket.as_mut().ok_or_else(denied)?; + let socket = managed.connection().await?; let attempt = scope.start_upstream(Some(UpstreamPathPurpose::Inference)); let result = socket.send(message, timeout()?).await; attempt.finish( @@ -275,6 +308,7 @@ pub(super) async fn dispatch( .as_ref() .map_or_else(|error| error.send_state, |()| UpstreamSendState::Sent), ); + managed.remove_closed_connection(&socket).await; result.map_err(super::http::http_error)?; Ok(RpcReply { result: serde_json::json!({}), @@ -285,12 +319,10 @@ pub(super) async fn dispatch( if params != serde_json::json!({}) || !payload.is_empty() { return Err(invalid()); } - let mut socket = managed.socket.try_lock().map_err(|_| denied())?; - let (_, connection) = socket.as_mut().ok_or_else(denied)?; - let result = connection - .read(timeout()?) - .await - .map_err(super::http::http_error)?; + let connection = managed.connection().await?; + let result = connection.read(timeout()?).await; + managed.remove_closed_connection(&connection).await; + let result = result.map_err(super::http::http_error)?; let (kind, payload) = match result { Some(WebSocketMessage::Text(text)) => { (Some(adapter::WebSocketMessageKind::Text), text.into_bytes()) @@ -298,13 +330,11 @@ pub(super) async fn dispatch( Some(WebSocketMessage::Binary(bytes)) => { (Some(adapter::WebSocketMessageKind::Binary), bytes.to_vec()) } - None => { - socket.take(); - (None, vec![]) - } + None => (None, vec![]), }; if payload.len() > maximum_payload { - socket.take(); + connection.close().await; + managed.remove_closed_connection(&connection).await; return Err(invalid()); } Ok(RpcReply { @@ -320,7 +350,10 @@ pub(super) async fn dispatch( if params != serde_json::json!({}) || !payload.is_empty() { return Err(invalid()); } - managed.socket.try_lock().map_err(|_| denied())?.take(); + let socket = managed.socket.lock().await.take(); + if let Some((_, socket)) = socket { + socket.close().await; + } Ok(RpcReply { result: serde_json::json!({}), payload: vec![], diff --git a/backend/crates/gateway-plugin/runtime/src/callback/websocket_middleware/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/websocket_middleware/mod.rs new file mode 100644 index 000000000..22a363603 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/websocket_middleware/mod.rs @@ -0,0 +1,194 @@ +//! 每次消息调用拥有自己的续体和正文句柄,发送端由连接 owner 持有。 +use super::MiddlewareCallback; +use crate::RpcReply; +use bytes::Bytes; +use futures::future::BoxFuture; +use gateway_core::middleware::websocket as core; +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::middleware::{ + BODY_CLOSE_METHOD, BODY_READ_METHOD, MiddlewareBodyClose, MiddlewareBodyRead, NEXT_METHOD, + websocket as wire, + }, +}; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +pub(crate) struct Invocation { + next: Mutex>, + payloads: Mutex>, + sender: Arc, +} +impl Invocation { + pub(crate) fn new(next: core::Next, sender: Arc) -> Arc { + Arc::new(Self { + next: Mutex::new(Some(next)), + payloads: Mutex::new(BTreeMap::new()), + sender, + }) + } + pub(crate) fn session(sender: Arc) -> Arc { + Arc::new(Self { + next: Mutex::new(None), + payloads: Mutex::new(BTreeMap::new()), + sender, + }) + } + pub(crate) fn owns_payload(&self, handle: &str) -> bool { + self.payloads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(handle) + } + pub(crate) fn encode(&self, message: core::Message) -> Result { + let mut payloads = self + .payloads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if payloads.len() >= 16 { + return Err(PluginFault::new( + ErrorCode::Capacity, + "WebSocket payload capacity reached; consume or close pending messages", + )); + } + let handle = uuid::Uuid::new_v4().to_string(); + payloads.insert(handle.clone(), message.payload); + Ok(wire::Message { + kind: wire_kind(message.kind), + payload: wire::Payload::Handle { handle }, + }) + } + pub(crate) fn decode( + &self, + message: wire::Message, + payload: Vec, + ) -> Result { + let payload = match message.payload { + wire::Payload::Bytes => Bytes::from(payload), + wire::Payload::Handle { handle } if payload.is_empty() => self + .payloads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&handle) + .ok_or_else(unavailable)?, + _ => return Err(invalid()), + }; + Ok(core::Message { + kind: kind(message.kind), + payload, + }) + } +} +impl MiddlewareCallback for Invocation { + fn invoke( + self: Arc, + method: String, + params: serde_json::Value, + payload: Vec, + maximum_payload: usize, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + let result = match method.as_str() { + NEXT_METHOD => { + let message = self.decode( + serde_json::from_value(params).map_err(|_| invalid())?, + payload, + )?; + let next = self + .next + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(unavailable)?; + let result = next + .run(message) + .await + .map_err(|error| super::error::middleware(&error))?; + serde_json::to_value(result.map(|message| self.encode(message)).transpose()?) + .map_err(|_| invalid())? + } + wire::SEND_METHOD => { + let message = self.decode( + serde_json::from_value(params).map_err(|_| invalid())?, + payload, + )?; + self.sender + .send(message) + .await + .map_err(|error| super::error::middleware(&error))?; + serde_json::json!({}) + } + BODY_READ_METHOD if payload.is_empty() => { + let read: MiddlewareBodyRead = + serde_json::from_value(params).map_err(|_| invalid())?; + let maximum = usize::try_from(read.maximum_bytes) + .map_err(|_| invalid())? + .min(maximum_payload); + if maximum == 0 { + return Err(invalid()); + } + let mut payloads = self + .payloads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let bytes = payloads.get_mut(&read.handle).ok_or_else(unavailable)?; + let eof = bytes.is_empty(); + let payload = bytes.split_to(maximum.min(bytes.len())).to_vec(); + if eof { + payloads.remove(&read.handle); + } + return Ok(RpcReply { + result: serde_json::to_value(wire::Read { eof }).map_err(|_| invalid())?, + payload, + }); + } + BODY_CLOSE_METHOD if payload.is_empty() => { + let close: MiddlewareBodyClose = + serde_json::from_value(params).map_err(|_| invalid())?; + self.payloads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&close.handle); + serde_json::json!({}) + } + _ => return Err(invalid()), + }; + Ok(RpcReply { + result, + payload: Vec::new(), + }) + }) + } +} +fn wire_kind(kind: core::Kind) -> wire::Kind { + match kind { + core::Kind::Text => wire::Kind::Text, + core::Kind::Binary => wire::Kind::Binary, + core::Kind::Ping => wire::Kind::Ping, + core::Kind::Pong => wire::Kind::Pong, + core::Kind::Close { code } => wire::Kind::Close { code }, + } +} +fn kind(kind: wire::Kind) -> core::Kind { + match kind { + wire::Kind::Text => core::Kind::Text, + wire::Kind::Binary => core::Kind::Binary, + wire::Kind::Ping => core::Kind::Ping, + wire::Kind::Pong => core::Kind::Pong, + wire::Kind::Close { code } => core::Kind::Close { code }, + } +} +fn invalid() -> PluginFault { + PluginFault::new( + ErrorCode::InvalidInput, + "WebSocket middleware input is invalid", + ) +} +fn unavailable() -> PluginFault { + PluginFault::new( + ErrorCode::Conflict, + "WebSocket resource was consumed or closed", + ) +} diff --git a/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs b/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs index 02cfba4a2..404b7e13a 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs @@ -7,13 +7,13 @@ use gateway_admin::model::{ state::PluginStateConfiguration, }, }; -use gateway_plugin_sdk::{Capability, Manifest, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Manifest, Stage}; use secrecy::ExposeSecret as _; pub(super) fn validate( instance: &PluginInstance, manifest: &Manifest, -) -> Result<(serde_json::Value, Vec, PluginStateConfiguration), AdminError> { +) -> Result<(serde_json::Value, PluginStateConfiguration), AdminError> { if instance.enabled && !instance.trusted_process { return Err(AdminError::invalid("插件制品尚未接受安装")); } @@ -25,15 +25,6 @@ pub(super) fn validate( if instance.enabled && !ready { return Err(AdminError::invalid("请填写插件必填配置")); } - let mut permissions = BTreeSet::new(); - for grant in &instance.grants { - let permission: Permission = - serde_json::from_value(serde_json::Value::String(grant.permission.clone())) - .map_err(|_| AdminError::invalid("插件权限标识不合法"))?; - if !manifest.permissions.contains(&permission) || !permissions.insert(permission) { - return Err(AdminError::invalid("插件权限未声明或重复")); - } - } let mut bindings = BTreeSet::new(); for binding in &instance.bindings { let resolved = crate::contribution::resolve(manifest, binding)?; @@ -46,9 +37,14 @@ pub(super) fn validate( } let stage: Stage = serde_json::from_value(serde_json::Value::String(binding.stage.clone())) .map_err(|_| AdminError::invalid("插件调用阶段不合法"))?; - if !bindings.insert((capability, stage)) || !resolved.declaration.stages.contains(&stage) { + if (capability != Capability::Observer && !bindings.insert((capability, stage))) + || !resolved.declaration.stages.contains(&stage) + { return Err(AdminError::invalid("插件能力或阶段未声明,或者重复绑定")); } + if capability != Capability::Observer && binding.event.is_some() { + return Err(AdminError::invalid("事件订阅只能用于观察绑定")); + } let frontend_authentication = capability == Capability::FrontendAuthentication; if frontend_authentication && (stage != Stage::Authentication @@ -72,7 +68,6 @@ pub(super) fn validate( } Ok(( configuration, - permissions.into_iter().collect(), crate::callback::private_state::configuration(manifest)?, )) } 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 8403a7196..e7ad891af 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs @@ -39,7 +39,7 @@ impl PluginRuntime { .clone() .try_acquire_owned() .map_err(|_| AdminError::unavailable("插件校验繁忙"))?; - let (package, configuration, instance, granted_permissions, state_configuration) = + let (package, configuration, instance, state_configuration) = tokio::task::spawn_blocking(move || { let _validation_slot = validation_slot; let package = Arc::new( @@ -50,7 +50,7 @@ impl PluginRuntime { ) .map_err(|_| AdminError::invalid("插件恢复包校验失败"))?, ); - let (configuration, permissions, state_configuration) = + let (configuration, state_configuration) = super::super::configuration::validate(&instance, package.manifest())?; crate::adapter::observer::validate_bindings( package.manifest(), @@ -86,13 +86,7 @@ impl PluginRuntime { .prepare(&directory, &host_version) .map_err(|_| AdminError::unavailable("插件制品准备失败"))?, ); - Ok(( - package, - configuration, - instance, - permissions, - state_configuration, - )) + Ok((package, configuration, instance, state_configuration)) }) .await .map_err(|_| AdminError::internal("插件校验任务失败"))??; @@ -144,7 +138,6 @@ impl PluginRuntime { generation: target_revision.get(), incarnation, configuration, - permissions: granted_permissions.clone(), contributes: manifest.contributes.clone(), }; let callbacks = Arc::new(PluginCallbacks::new( @@ -154,8 +147,8 @@ impl PluginRuntime { self.log_slots.clone(), private_state.clone(), PluginCallbackPorts::new( + self.service_ports.clone(), self.http.clone(), - self.network_policy.clone(), self.account_ports.clone(), self.client_key_ports.clone(), self.model_ports.clone(), @@ -223,7 +216,6 @@ impl PluginRuntime { &plugin_id, &instance_id, &bindings, - &granted_permissions, session.clone(), callbacks.clone(), )? { @@ -233,7 +225,6 @@ impl PluginRuntime { &manifest, &instance_id, &bindings, - &granted_permissions, session.clone(), callbacks.clone(), )?); diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs index b22e14b8c..d6bb378ef 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs @@ -82,6 +82,7 @@ pub struct PluginRuntimeConfig { /// 只索引有主人的候选;当前代次仅由 Core 的发布视图持有。 pub struct PluginRuntime { + service_ports: Arc, pub(super) store: Arc, state: Arc, config: PluginRuntimeConfig, @@ -105,7 +106,6 @@ pub struct PluginRuntime { authentications: FrontendAuthenticationExtensionIndex, oauth_pending: Option>, http: Arc, - network_policy: gateway_host::outbound::NetworkPolicy, } #[derive(Clone)] @@ -238,6 +238,7 @@ impl PluginRuntime { shutdown_lock: Mutex::new(()), processes, validators: Arc::new(Semaphore::new(2)), + service_ports: Arc::new(crate::callback::services::ServicePorts::default()), account_ports: Arc::new(PluginAccountPortSlot::new()), client_key_ports: Arc::new(PluginClientKeyPortSlot::new()), resource_ports: Arc::new(crate::callback::PluginResourcePorts::new()), @@ -249,7 +250,6 @@ impl PluginRuntime { authentications: FrontendAuthenticationExtensionIndex::default(), oauth_pending: None, http, - network_policy: gateway_host::outbound::NetworkPolicy::default(), } } @@ -259,14 +259,21 @@ impl PluginRuntime { self } - /// 宿主统一决定可到达的地址段;插件配置与请求不能自行放宽 DNS/IP 边界。 - #[must_use] - pub fn with_network_policy(mut self, policy: gateway_host::outbound::NetworkPolicy) -> Self { - self.network_policy = policy; - self + /// API 完成组装后绑定唯一内部 HTTP 分派端口;不持有服务端强引用。 + pub fn bind_http( + &self, + dispatcher: &Arc, + ) -> Result<(), AdminError> { + self.service_ports.bind_http(dispatcher) + } + + pub fn bind_services( + &self, + registry: &Arc, + ) -> Result<(), AdminError> { + self.service_ports.bind(registry) } - /// Admin 初始化后一次性绑定窄账号端口;Runtime 仅保存 Weak,避免组合根强环。 pub fn bind_account_ports( &self, access: &Arc, @@ -274,7 +281,7 @@ impl PluginRuntime { self.account_ports.bind(access) } - /// Key 目录与预算端口由 Admin 组合并保活,Runtime 不取得明文访问端口。 + /// Key 目录与预算端口由 Admin 组合并保活,完整字段由对应公开合同返回。 pub fn bind_client_key_ports( &self, access: &Arc, @@ -725,23 +732,17 @@ impl PluginRuntime { #[async_trait] impl gateway_admin::ports::plugin_management::PluginManagement for PluginRuntime { - async fn authorize_models( + async fn validate_target( &self, published: &ExtensionSetReference, target: &gateway_admin::model::plugins::management::PluginManagementTarget, ) -> Result<(), AdminError> { let set = self.prepared_set(published).await?; - let entry = set - .management + set.management .iter() .find(|entry| &entry.view.target == target) .ok_or_else(|| AdminError::conflict("插件页面版本已变化,请刷新页面"))?; - if !entry.models_authorized { - return Err(AdminError::new( - gateway_admin::model::AdminErrorKind::Forbidden, - "插件未声明模型访问能力", - )); - } + Ok(()) } @@ -878,7 +879,7 @@ impl PluginPreparation for PluginRuntime { let package = ValidatedPackage::read(artifact.archive, Some(&instance.artifact_sha256), limits) .map_err(|_| AdminError::invalid("插件制品校验失败"))?; - let (_, _, state) = super::configuration::validate(&instance, package.manifest())?; + let (_, state) = super::configuration::validate(&instance, package.manifest())?; crate::adapter::observer::validate_bindings(package.manifest(), &instance.bindings)?; crate::adapter::policy::validate_bindings(package.manifest(), &instance.bindings)?; crate::adapter::catalog::validate_bindings(package.manifest(), &instance.bindings)?; @@ -1238,7 +1239,7 @@ fn instance_fingerprint( .iter() .map(|(name, value)| (name, value.expose_secret())) .collect(); - let mut value = serde_json::json!({"id":instance.id,"artifact":instance.artifact_sha256,"revision":instance.revision.get(),"trusted":instance.trusted_process,"configuration":instance.configuration,"secrets":secrets,"grants":instance.grants,"bindings":instance.bindings}); + let mut value = serde_json::json!({"id":instance.id,"artifact":instance.artifact_sha256,"revision":instance.revision.get(),"trusted":instance.trusted_process,"configuration":instance.configuration,"secrets":secrets,"bindings":instance.bindings}); // JSONB 恢复可以改变对象键顺序,不能因此替换已经准备好的同一配置。 value.sort_all_objects(); let bytes = serde_json::to_vec(&value).map_err(|_| AdminError::invalid("插件配置无法编码"))?; diff --git a/backend/crates/gateway-plugin/runtime/src/lib.rs b/backend/crates/gateway-plugin/runtime/src/lib.rs index 79782d9ac..35031a4f8 100644 --- a/backend/crates/gateway-plugin/runtime/src/lib.rs +++ b/backend/crates/gateway-plugin/runtime/src/lib.rs @@ -1,4 +1,4 @@ -//! 插件包校验与线协议适配;安装选择和授权事实由 Admin/Store 拥有。 +//! 插件包校验与线协议适配;安装选择和制品接受事实由 Admin/Store 拥有。 mod adapter; mod callback; diff --git a/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs index 2db825a74..304dbeb45 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs @@ -4,7 +4,7 @@ use gateway_admin::model::{ AdminError, plugins::{PluginCompatibilityRequirements, PluginHostCompatibility}, }; -use gateway_plugin_sdk::{Capability, MANIFEST_VERSION, Manifest, PROTOCOL_VERSION, Permission}; +use gateway_plugin_sdk::{Capability, MANIFEST_VERSION, Manifest, PROTOCOL_VERSION}; const HOST_COMPATIBILITY_JSON: &str = include_str!("../../plugin-host-compatibility.json"); @@ -33,9 +33,6 @@ pub(crate) fn host_compatibility() -> Result<&'static PluginHostCompatibility, A .all(|version| capability.contract_versions().contains(version)) }, ) - }) - && compatibility.permissions.iter().all(|permission| { - serde_json::from_value::(permission.clone().into()).is_ok() }); (compatibility.is_valid() && known_contracts) .then_some(compatibility) @@ -63,11 +60,6 @@ pub(crate) fn requirements( (capability.identifier().to_owned(), declaration.version) }) .collect(), - permissions: manifest - .permissions - .iter() - .map(|permission| permission.as_str().to_owned()) - .collect(), }) } @@ -83,9 +75,5 @@ pub(crate) fn supports(manifest: &Manifest) -> Result { && requirements .capabilities .iter() - .all(|(capability, version)| compatibility.supports_capability(capability, *version)) - && requirements - .permissions - .iter() - .all(|permission| compatibility.supports_permission(permission))) + .all(|(capability, version)| compatibility.supports_capability(capability, *version))) } diff --git a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs index e9c00e2e1..d8543a54d 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/inspection.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/inspection.rs @@ -35,28 +35,6 @@ impl PackageInspector { #[async_trait] impl PluginPackageInspector for PackageInspector { - fn permission_descriptions( - &self, - permissions: &[String], - ) -> Vec { - permissions - .iter() - .map(|identifier| { - let permission = serde_json::from_value::( - serde_json::Value::String(identifier.clone()), - ) - .ok(); - gateway_admin::model::plugins::PluginPermissionDescription { - permission: identifier.clone(), - label: permission - .map_or(identifier.as_str(), |value| value.label()) - .into(), - description: permission.map_or("", |value| value.description()).into(), - } - }) - .collect() - } - async fn inspect( &self, archive: Arc<[u8]>, @@ -123,11 +101,6 @@ impl PluginPackageInspector for PackageInspector { )) }) .collect::>()?, - requested_permissions: manifest - .permissions - .iter() - .map(identifier) - .collect::>()?, configuration_schema: manifest.configuration_schema.clone(), secret_fields: manifest.secret_fields.iter().cloned().collect(), state_namespaces: crate::callback::private_state::configuration(manifest)? diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs index bc67b9554..1b3975ca1 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use gateway_plugin_sdk::{ - ErrorCode, Frame, Message, Permission, PluginFault, Stage, + ErrorCode, Frame, Message, PluginFault, client::{read_frame, validate_frame, write_frame}, }; use tokio::{ @@ -18,13 +18,12 @@ pub(super) fn start( control: mpsc::Receiver, shared: Arc, callbacks: Arc, - permissions: Vec, ) where W: AsyncWrite + Unpin + Send + 'static, R: AsyncRead + Unpin + Send + 'static, { tokio::spawn(write_loop(writer, data, control, Arc::clone(&shared))); - tokio::spawn(read_loop(reader, shared, callbacks, permissions)); + tokio::spawn(read_loop(reader, shared, callbacks)); } async fn write_loop( @@ -74,7 +73,6 @@ async fn read_loop( mut reader: R, shared: Arc, callbacks: Arc, - permissions: Vec, ) { let mut stopped = shared.stopped.subscribe(); let mut last_callback = 0; @@ -117,15 +115,7 @@ async fn read_loop( Err(RpcError::Protocol) } else if let Some(context) = shared.context(parent_id) { last_callback = id; - if !callback_allowed(&method, context.stage, &permissions) { - shared.send_control(Frame::control(Message::Error { - id, - error: PluginFault::new( - ErrorCode::PermissionDenied, - "callback is not authorized in this stage", - ), - })); - } else if let Some(permit) = shared.try_callback_slot() { + if let Some(permit) = shared.try_callback_slot() { let handler = Arc::clone(&callbacks); let response = Arc::clone(&shared); let (ready, started) = oneshot::channel(); @@ -190,135 +180,3 @@ async fn read_loop( } } } - -fn callback_allowed(method: &str, stage: Stage, permissions: &[Permission]) -> bool { - // 未登录端点只服务声明的公开内容,不能借宿主回调取得管理资源。 - if stage == Stage::PublicManagement { - return false; - } - if method == "host.log" { - return true; - } - if matches!(stage, Stage::Registration | Stage::Configuration) { - return false; - } - if matches!( - method, - "host.state.get" | "host.state.put" | "host.state.delete" - ) { - return true; - } - // 失败后的策略不得产生额外出站调用或读取账号凭据。 - if stage == Stage::Retry { - return false; - } - if matches!( - method, - gateway_plugin_sdk::call::middleware::NEXT_METHOD - | gateway_plugin_sdk::call::middleware::BODY_READ_METHOD - | gateway_plugin_sdk::call::middleware::BODY_CLOSE_METHOD - ) { - return matches!(stage, Stage::Request | Stage::Attempt); - } - if stage == Stage::Maintenance - && !matches!( - method, - "host.data.accounts.list" - | "host.data.keys.get" - | "host.data.quota.get" - | "host.quota_observations.refresh" - | "host.groups.ensure" - | "host.groups.change_members" - | "host.keys.ensure" - | "host.keys.list" - | "host.keys.reset_budget" - | "host.keys.get_budget" - | "host.keys.update_budget_limits" - ) - { - return false; - } - if method == "host.data.quota.get" { - return matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) && (permissions.contains(&Permission::Data) - || permissions.contains(&Permission::QuotaObservations)); - } - if method == "host.keys.list" { - return (stage != Stage::Maintenance && permissions.contains(&Permission::Models)) - || (matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) && permissions.contains(&Permission::KeyBudgets)); - } - let permission = match method { - "host.upstream.http.do" - | "host.upstream.http.do_stream" - | "host.upstream.http.stream_read" - | "host.upstream.http.stream_close" - | "host.upstream.websocket.open" - | "host.upstream.websocket.send" - | "host.upstream.websocket.read" - | "host.upstream.websocket.close" - if stage == Stage::Upstream => - { - Permission::UpstreamConnections - } - "host.http.do" - | "host.http.do_stream" - | "host.http.stream_read" - | "host.http.stream_close" => Permission::Network, - "host.model.execute" - | "host.model.execute_stream" - | "host.model.stream_read" - | "host.model.stream_close" - | "host.models.list" => Permission::Models, - "host.auth.list" | "host.auth.get_runtime" | "host.auth.get" | "host.auth.save" => { - Permission::Accounts - } - "host.affinity.lookup" => Permission::Requests, - "host.data.accounts.list" | "host.data.keys.get" - if matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) => - { - Permission::Data - } - "host.quota_observations.refresh" - if matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) => - { - Permission::QuotaObservations - } - "host.groups.ensure" | "host.groups.change_members" - if matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) => - { - Permission::Groups - } - "host.keys.ensure" - if matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) => - { - Permission::Keys - } - "host.keys.reset_budget" | "host.keys.get_budget" | "host.keys.update_budget_limits" - if matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) => - { - Permission::KeyBudgets - } - _ => return false, - }; - permissions.contains(&permission) -} diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index b9a6e8374..44973fed6 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -118,6 +118,9 @@ pub trait CallbackHandler: Send + Sync { /// 调用结束或取消时立即撤销其句柄;实现必须幂等且不能阻塞。 fn finish(&self, _context: &CallContext) {} + /// 首个流式结果已到达;资源仍绑定原调用并在终态统一回收。 + fn streaming(&self, _context: &CallContext) {} + fn call( &self, context: CallContext, @@ -226,6 +229,7 @@ impl Shared { } if call.stream.is_some() && result.is_ok() { let initial = call.result.take().ok_or(RpcError::Protocol)?; + call.handler.streaming(&call.context); let _ = initial.send(result); } else { let pending = state.calls.remove(&id).ok_or(RpcError::Protocol)?; @@ -480,10 +484,6 @@ impl RpcSession { || handshake.plugin_id != plugin_id || handshake.artifact_sha256 != package.package().digest() || handshake.contributes != manifest.contributes - || handshake - .permissions - .iter() - .any(|permission| !manifest.permissions.contains(permission)) || handshake.instance_id.is_empty() || handshake.incarnation.is_empty() || handshake.generation == 0 @@ -584,7 +584,6 @@ impl RpcSession { control_receiver, Arc::clone(&shared), callbacks.clone(), - handshake.permissions.clone(), ); let monitor = Arc::clone(&shared); let instance_id = handshake.instance_id.clone(); @@ -658,6 +657,7 @@ impl RpcSession { incarnation: self.handshake.incarnation.clone(), stage, timeout_ms: timeout.as_millis().min(u128::from(u64::MAX)) as u64, + resource_stream: false, resource_scope_id: uuid::Uuid::new_v4().to_string(), request_id: None, attempt_id: None, @@ -835,6 +835,7 @@ impl RpcSession { let bytes = self.limits.maximum_buffered_body_bytes.min(256 * 1024) as u32; let frames = 32; let (stream, chunks, terminal) = StreamIngress::new(bytes, frames); + let resource_stream = context.resource_stream; let completed = self .send_call( method, @@ -849,7 +850,7 @@ impl RpcSession { initial: completed.reply, chunks, terminal: Some(terminal), - deadline: completed.deadline, + deadline: (!resource_stream).then_some(completed.deadline), id: completed.id, shared: Arc::clone(&self.shared), _slot: slot, diff --git a/backend/crates/gateway-plugin/runtime/src/stream/bridge.rs b/backend/crates/gateway-plugin/runtime/src/stream/bridge.rs index 77d05e355..1415178d5 100644 --- a/backend/crates/gateway-plugin/runtime/src/stream/bridge.rs +++ b/backend/crates/gateway-plugin/runtime/src/stream/bridge.rs @@ -58,7 +58,7 @@ pub struct RpcStream { pub initial: RpcReply, pub(crate) chunks: mpsc::Receiver>, pub(crate) terminal: Option>>, - pub(crate) deadline: Instant, + pub(crate) deadline: Option, pub(crate) id: u64, pub(crate) shared: Arc, pub(crate) _slot: OwnedSemaphorePermit, @@ -70,7 +70,11 @@ impl RpcStream { if self.terminal.is_none() { return Ok(None); } - match tokio::time::timeout_at(self.deadline, self.chunks.recv()).await { + let result = match self.deadline { + Some(deadline) => tokio::time::timeout_at(deadline, self.chunks.recv()).await, + None => Ok(self.chunks.recv().await), + }; + match result { Ok(Some(chunk)) => { self.shared .release_stream_credit(self.id, chunk.len() as u32)?; diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/execution.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/execution.rs index 84380b8fd..37b54d7cb 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/execution.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/execution.rs @@ -2,7 +2,7 @@ use gateway_core::policy::{ClientApiKeyId, RateLimits}; use gateway_plugin_runtime::PluginCommandError; use serde_json::json; -use crate::support::environment::{Environment, account_grant}; +use crate::support::environment::Environment; #[tokio::test] async fn command_selects_key_at_call_time_and_uses_normal_model_ledger() { @@ -29,34 +29,29 @@ async fn command_selects_key_at_call_time_and_uses_normal_model_ledger() { .path() .join("command-bound-model.jsonl"); let command_marker = environment.directory.path().join("command-bound.jsonl"); - let model_grant = account_grant("models"); - let source_credentials = account_grant("accounts"); environment - .install_plugin( - json!({ - "plugin_id":"test.command-source", - "command_registration":{"commands":[ - {"name":"run","description":"执行模型"}, - {"name":"fail","description":"执行后失败"} - ]}, - "command_nested_model_fixture":{ - "request":{ - "client_key_id":key_id, - "model":crate::support::native::MODEL, - "protocol":"openai", - "operation":"generate", - "provider":"openai", - "account_id":account.as_str() - }, - "body":{"model":crate::support::native::MODEL,"input":"bound command"} + .install_plugin(json!({ + "plugin_id":"test.command-source", + "command_registration":{"commands":[ + {"name":"run","description":"执行模型"}, + {"name":"fail","description":"执行后失败"} + ]}, + "command_nested_model_fixture":{ + "request":{ + "client_key_id":key_id, + "model":crate::support::native::MODEL, + "protocol":"openai", + "operation":"generate", + "provider":"openai", + "account_id":account.as_str() }, - "command_nested_model_marker":marker, - "command_marker":command_marker, - "command_error_after_nested_model":"fail", - "command_result":{"stdout":"ok\n","stderr":"","exit_code":0} - }), - vec![model_grant, source_credentials], - ) + "body":{"model":crate::support::native::MODEL,"input":"bound command"} + }, + "command_nested_model_marker":marker, + "command_marker":command_marker, + "command_error_after_nested_model":"fail", + "command_result":{"stdout":"ok\n","stderr":"","exit_code":0} + })) .await; let (runtime, core) = environment.command_plane().await; let snapshot = environment @@ -164,9 +159,7 @@ async fn command_accounts_use_admin_cas_audit_and_report_partial_commit_without_ {"action":"replace","account_id":existing.as_str(),"credential_revision":999,"facts":facts} ]} }); - let (runtime, core) = environment - .plugin(config, vec![account_grant("accounts")]) - .await; + let (runtime, core) = environment.plugin(config).await; let admin = environment.bind_admin_accounts(&runtime, &core).await; let snapshot = environment .store @@ -249,9 +242,7 @@ async fn command_accounts_domain_does_not_need_separate_login_or_write_grants() {"action":"create","provider_id":"openai","facts":{"name":"CLI created","authentication_kind":"api_key","material":{"key":"cli-test-only"}}} ]} }); - let (runtime, core) = environment - .plugin(config, vec![account_grant("accounts")]) - .await; + let (runtime, core) = environment.plugin(config).await; let admin = environment.bind_admin_accounts(&runtime, &core).await; let snapshot = environment .store 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 50e07a48c..3eb326824 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 @@ -32,7 +32,6 @@ async fn setup_with_limits( let directory = tempfile::tempdir().unwrap(); let archive = crate::support::package_with_contributions( crate::support::worker(), - vec![], Contributions::from([crate::support::contribution( Capability::CommandLine, vec![Stage::CommandLine], @@ -52,7 +51,7 @@ async fn setup_with_limits( trusted_process: true, configuration, secrets: BTreeMap::new(), - grants: vec![], + bindings: vec![], revision: Revision::new(1).unwrap(), }; diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/management/callback.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/management/callback.rs index 97c361f8b..2e24309f2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/management/callback.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/management/callback.rs @@ -11,6 +11,10 @@ use super::{Environment, install, registration}; fn request() -> PluginManagementRequest { PluginManagementRequest { + headers: vec![gateway_core::engine::middleware::MiddlewareHeader::new( + "cookie", + "callback=fixture".into(), + )], method: "GET".into(), path: "oauth".into(), query: "code=synthetic-code".into(), @@ -104,6 +108,9 @@ async fn public_login_callbacks_consume_bound_state_once_and_reject_wrong_owner_ assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1); let response = one.or(two).unwrap(); assert_eq!(response.body.as_ref(), b"callback received"); + assert_eq!(response.headers.len(), 1); + assert_eq!(response.headers[0].name(), "cookie"); + assert_eq!(response.headers[0].value().as_ref(), b"callback=fixture"); assert!( service .callback(target, &ticket.state, request()) 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 c842d3bdb..84d51ee8e 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/management/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/management/mod.rs @@ -19,7 +19,7 @@ use sha2::{Digest as _, Sha256}; use crate::support::{ self, - environment::{Environment, account_grant, mutation}, + environment::{Environment, mutation}, }; fn registration() -> Value { @@ -51,11 +51,10 @@ async fn install(environment: &Environment, config: Value) -> PluginInstance { .map(|(name, bytes)| (name.clone(), hex::encode(Sha256::digest(bytes)))) .collect(); let manifest = json!({ - "manifestVersion":1,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0", + "manifestVersion":2,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0", "engines":{"codex-proxy-rs":">=1.0.0, <2.0.0"},"main":"bin/worker","author":"test","description":"管理测试","license":"MIT", "runtime":"trustedProcess","resources":{"ui/index.html":"text/html","ui/app.js":"text/javascript","ui/public.svg":"image/svg+xml"}, "contributes":{"management":{"id":"management.example.management","version":1,"stages":["management"],"inputFormats":[],"outputFormats":[]}}, - "permissions":["public_endpoints"], "package":{"protocolVersion":gateway_plugin_sdk::PROTOCOL_VERSION,"target":{"os":std::env::consts::OS,"architecture":std::env::consts::ARCH},"files":digests}, }); files.insert("plugin.json".into(), serde_json::to_vec(&manifest).unwrap()); @@ -81,7 +80,7 @@ async fn install(environment: &Environment, config: Value) -> PluginInstance { configuration: config, secrets: BTreeMap::new(), bindings: vec![], - grants: vec![serde_json::from_value(json!({"permission":"public_endpoints"})).unwrap()], + revision: Revision::new(1).unwrap(), }; store @@ -93,6 +92,7 @@ async fn install(environment: &Environment, config: Value) -> PluginInstance { fn request() -> PluginManagementRequest { PluginManagementRequest { + headers: Vec::new(), method: "POST".into(), path: "echo".into(), query: "unicode=%E4%B8%AD".into(), @@ -123,9 +123,6 @@ async fn management_model_stream_retains_selected_identity_until_completion_and_ .directory .path() .join("management-unbound-model.jsonl"); - let model_grant = account_grant("models"); - let bound_credentials = account_grant("accounts"); - let unbound_credentials = account_grant("accounts"); let nested_request = json!({ "stream":true, "request":{ @@ -156,7 +153,7 @@ async fn management_model_stream_retains_selected_identity_until_completion_and_ "nested_stream_trace_marker":environment.directory.path().join("management-stream.jsonl"), "management_marker":bound_calls, }), - vec![model_grant.clone(), bound_credentials], + ) .await; let mut denied_request = nested_request; @@ -167,15 +164,12 @@ async fn management_model_stream_retains_selected_identity_until_completion_and_ .remove("client_key_id"); denied_request["expect_error"] = json!(true); environment - .install_plugin( - json!({ - "plugin_id":"test.management-unbound", - "management_registration":model_route, - "management_nested_model_fixture":denied_request, - "management_nested_model_marker":unbound_nested, - }), - vec![model_grant, unbound_credentials], - ) + .install_plugin(json!({ + "plugin_id":"test.management-unbound", + "management_registration":model_route, + "management_nested_model_fixture":denied_request, + "management_nested_model_marker":unbound_nested, + })) .await; let (runtime, core) = environment.runtime().await; let service = PluginManagementService::new( @@ -295,7 +289,7 @@ async fn management_resources_and_raw_calls_are_version_bound_and_revocation_rem let marker = environment.directory.path().join("management-calls.jsonl"); install( &environment, - json!({"management_registration":registration(),"management_marker":marker}), + json!({"management_registration":registration(),"management_marker":marker,"management_echo_headers":true}), ) .await; let (runtime, core) = environment.runtime().await; @@ -346,9 +340,33 @@ async fn management_resources_and_raw_calls_are_version_bound_and_revocation_rem assert!(service.resource(target, path, false).await.is_err()); } assert!(!marker.exists(), "静态资源读取不能执行插件 handler"); - let response = service.handle(target, request()).await.unwrap(); + let mut raw_request = request(); + raw_request.headers = vec![ + gateway_core::engine::middleware::MiddlewareHeader::new( + "authorization", + "Bearer fixture".into(), + ), + gateway_core::engine::middleware::MiddlewareHeader::new("set-cookie", "first=1".into()), + gateway_core::engine::middleware::MiddlewareHeader::new( + "set-cookie", + bytes::Bytes::from_static(&[0xff]), + ), + ]; + let response = service.handle(target, raw_request).await.unwrap(); assert_eq!(response.status, 200); assert_eq!(response.body.as_ref(), &[0, 255, 128, 10]); + assert_eq!( + response + .headers + .iter() + .map(|header| (header.name(), header.value().as_ref())) + .collect::>(), + [ + ("authorization", b"Bearer fixture".as_slice()), + ("set-cookie", b"first=1".as_slice()), + ("set-cookie", [0xff].as_slice()), + ] + ); let mut invalid = request(); invalid.content_type = Some("text/html".into()); assert!(service.handle(target, invalid).await.is_err()); diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs index aee929c8c..c19a6166f 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs @@ -57,9 +57,7 @@ async fn candidate_rejects_undeclared_resources_invalid_routes_and_missing_publi candidate.instances[0].configuration["management_registration"][field] = value; assert!(runtime.prepare(candidate).await.is_err(), "{field}"); } - let mut candidate = source.clone(); - candidate.instances[0].grants.clear(); - assert!(runtime.prepare(candidate).await.is_err()); + assert!(runtime.prepare(source).await.is_ok()); drop(core); drop(runtime); environment.close().await; diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs b/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs index 12fdd43ab..19d750bb3 100644 --- a/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs +++ b/backend/crates/gateway-plugin/runtime/tests/callback/key_facts.rs @@ -1,7 +1,7 @@ use gateway_admin::{ model::{ client_keys::{SetClientKeyEnabled, UpdateClientKey}, - plugins::{instances::PluginPermissionGrant, management::PluginManagementRequest}, + plugins::management::PluginManagementRequest, }, ports::plugin_management::PluginManagement, }; @@ -25,7 +25,7 @@ async fn key_facts_read_current_database_groups_without_secrets() { {"method":"host.data.keys.get","query":{"client_key_id":"key_missing"}}, {"method":"host.data.keys.get","query":{"client_key_id":id,"secret":true}} ] - }), vec![PluginPermissionGrant { permission: "data".into() }]).await; + })).await; let access = gateway_admin::initialize_plugin_client_keys( crate::support::native::admin_registry(), environment.store.admin_ports().client_keys(), @@ -81,6 +81,7 @@ async fn key_facts_read_current_database_groups_without_secrets() { &generation, &view.target, PluginManagementRequest { + headers: Vec::new(), method: "GET".into(), path: "facts".into(), query: String::new(), diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/keys.rs b/backend/crates/gateway-plugin/runtime/tests/callback/keys.rs index bc3db9fb3..e4437d25c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/callback/keys.rs +++ b/backend/crates/gateway-plugin/runtime/tests/callback/keys.rs @@ -19,14 +19,11 @@ use gateway_core::{ use serde_json::{Value, json}; use tokio::{sync::Notify, time::timeout}; -use crate::support::{ - environment::{Environment, account_grant}, - native, -}; +use crate::support::{environment::Environment, native}; #[tokio::test] async fn plugin_process_manages_native_budget_through_client_key_service() { - for granted in [false, true] { + { let Some(environment) = Environment::create().await else { eprintln!("SKIP: plugin integration environment absent"); return; @@ -56,7 +53,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() { {"method":"host.keys.update_budget_limits","query":{"client_key_id":"key_budget","max_concurrency":1}}, {"method":"host.keys.get_budget","query":{"client_key_id":"key_budget","instance_id":"forged"}} ] - }), if granted { vec![account_grant("key_budgets")] } else { vec![] }).await; + })).await; let (runtime, core) = environment.runtime().await; let access = gateway_admin::initialize_plugin_client_keys( native::admin_registry(), @@ -74,6 +71,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() { .handle( &view.target, PluginManagementRequest { + headers: Vec::new(), method: "POST".into(), path: "reset".into(), query: String::new(), @@ -87,7 +85,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() { let results: Vec = serde_json::from_slice(&reply.body).unwrap(); let after = store.get_client_key(&key).await.unwrap().unwrap(); let audits = environment.audit_requests("reset_budget").await; - if granted { + { assert_eq!( results[0], json!({"keys":[{"id":"key_budget","name":"fixture key_budget","enabled":true}],"next_cursor":null}) @@ -134,14 +132,6 @@ async fn plugin_process_manages_native_budget_through_client_key_service() { ); assert_eq!(audits.len(), 1); assert!(audits[0].starts_with(&format!("plugin:{}:scope:", view.target.instance_id))); - } else { - assert!( - results - .iter() - .all(|value| value == &json!({"error":"permission_denied"})) - ); - assert_eq!(after.budget, before.budget); - assert!(audits.is_empty()); } assert_eq!( store @@ -203,13 +193,10 @@ async fn command_plane_resets_budget_with_only_budget_permission() { }; let key = seed_budget(&environment).await; environment - .install_plugin( - json!({ - "command_registration":{"commands":[{"name":"reset","description":"预算接口测试"}]}, - "data_queries":reset_queries() - }), - vec![account_grant("key_budgets")], - ) + .install_plugin(json!({ + "command_registration":{"commands":[{"name":"reset","description":"预算接口测试"}]}, + "data_queries":reset_queries() + })) .await; let (runtime, core) = environment.command_plane().await; let store = environment.store.admin_ports().client_keys(); @@ -265,14 +252,11 @@ async fn published_maintenance_resets_budget_with_only_budget_permission() { .path() .join("budget-maintenance.jsonl"); environment - .install_plugin( - json!({ - "maintenance_fixture":true, - "maintenance_marker":marker, - "data_queries":reset_queries() - }), - vec![account_grant("key_budgets")], - ) + .install_plugin(json!({ + "maintenance_fixture":true, + "maintenance_marker":marker, + "data_queries":reset_queries() + })) .await; let (runtime, core) = environment.runtime().await; let store = environment.store.admin_ports().client_keys(); @@ -393,7 +377,7 @@ async fn losing_callback_reply_after_commit_keeps_budget_and_audit_without_repla "startup_marker":startups, "exit_after_ready_signals":[disconnect] }), - vec![account_grant("key_budgets")], + ) .await; let (runtime, core) = environment.runtime().await; @@ -419,6 +403,7 @@ async fn losing_callback_reply_after_commit_keeps_budget_and_audit_without_repla .handle( &view.target, PluginManagementRequest { + headers: Vec::new(), method: "POST".into(), path: "reset".into(), query: String::new(), diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/log.rs b/backend/crates/gateway-plugin/runtime/tests/callback/log.rs index 56f18f410..277b74320 100644 --- a/backend/crates/gateway-plugin/runtime/tests/callback/log.rs +++ b/backend/crates/gateway-plugin/runtime/tests/callback/log.rs @@ -123,10 +123,7 @@ async fn real_worker_logs_are_correlated_sanitized_validated_and_bounded() { entries.push(json!({"params":{"event":"fixture.resumed"},"delay_ms":1100})); let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) - .inspect( - crate::support::package_with_permissions(crate::support::worker(), vec![]), - None, - ) + .inspect(crate::support::package(crate::support::worker()), None) .await .unwrap(); let instance = PluginInstance { @@ -137,7 +134,7 @@ async fn real_worker_logs_are_correlated_sanitized_validated_and_bounded() { trusted_process: true, configuration: json!({"log_method":"plugin.register","log_entries":entries,"log_marker":marker}), secrets: BTreeMap::new(), - grants: vec![], + bindings: vec![], revision: Revision::new(1).unwrap(), }; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs b/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs index f09d68aa3..f170223a2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs @@ -5,7 +5,7 @@ use gateway_core::{ }; use serde_json::{Value, json}; -use crate::support::environment::{Environment, account_grant, mutation}; +use crate::support::environment::{Environment, mutation}; async fn authenticate( configuration: Value, @@ -20,9 +20,7 @@ async fn authenticate( environment .client_key("key_frontend", "sk-native-fixture") .await; - let (runtime, core) = environment - .plugin(configuration, vec![account_grant("accounts")]) - .await; + let (runtime, core) = environment.plugin(configuration).await; let result = core .execution_service() .authenticate_request(ClientAuthenticationRequest::new(authorization).unwrap()) diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs b/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs index 701bdd23e..ee8d34e95 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs @@ -1,7 +1,5 @@ use gateway_admin::{ - model::plugins::instances::{ - PluginCapabilityBinding, PluginFailurePolicy, PluginPermissionGrant, - }, + model::plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy}, ports::plugins::PluginPreparation, }; use gateway_plugin_sdk::{Capability, Contributions, Stage}; @@ -108,16 +106,13 @@ async fn schema_rejection_identifies_the_field_without_exposing_configuration_va } #[tokio::test] -async fn process_execution_requires_trust_and_undeclared_permissions_are_rejected() { +async fn process_execution_requires_explicit_trust() { let (_cache, store, runtime) = super::setup().await; let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); instance.trusted_process = false; assert!(runtime.validate(instance.clone()).await.is_err()); instance.trusted_process = true; - instance.grants.push(PluginPermissionGrant { - permission: "accounts".into(), - }); - assert!(runtime.validate(instance).await.is_err()); + assert!(runtime.validate(instance).await.is_ok()); } #[tokio::test] @@ -134,7 +129,7 @@ async fn secrets_must_be_declared_by_the_package() { async fn enabled_bindings_must_reference_the_exact_declared_contribution_and_stage() { let (_cache, store, runtime) = super::setup_with_contributions(Contributions::from([crate::support::contribution( - Capability::Usage, + Capability::Observer, vec![Stage::Observation], vec![], vec![], @@ -143,7 +138,7 @@ async fn enabled_bindings_must_reference_the_exact_declared_contribution_and_sta let instance = store.snapshot.lock().unwrap().instances[0].clone(); let mut valid = instance.clone(); valid.bindings = vec![PluginCapabilityBinding { - contribution: "test.example.usage".into(), + contribution: "test.example.observer".into(), stage: "observation".into(), order: 0, failure_policy: PluginFailurePolicy::Observe, @@ -151,6 +146,7 @@ async fn enabled_bindings_must_reference_the_exact_declared_contribution_and_sta account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: Some("request_completed".into()), identity_bindings: vec![], }]; assert!(runtime.validate(valid.clone()).await.is_ok()); @@ -158,7 +154,7 @@ async fn enabled_bindings_must_reference_the_exact_declared_contribution_and_sta for (contribution, stage) in [ ("test.example.unknown", "observation"), ("other.example.usage", "observation"), - ("test.example.usage", "routing"), + ("test.example.observer", "routing"), ] { let mut candidate = valid.clone(); candidate.bindings[0].contribution = contribution.into(); diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs index d2a27bbc2..777a64455 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs @@ -6,10 +6,8 @@ use std::sync::{ use async_trait::async_trait; use gateway_admin::{ model::{ - AdminError, MutationContext, Revision, - accounts::AccountRecord, - plugins::{instances::PluginPermissionGrant, management::PluginManagementRequest}, - provider_credentials::*, + AdminError, MutationContext, Revision, accounts::AccountRecord, + plugins::management::PluginManagementRequest, provider_credentials::*, }, ports::{plugin_accounts::PluginAccountAccess, plugin_management::PluginManagement}, }; @@ -18,7 +16,7 @@ use gateway_core::{ routing::{ConfigRevision, ProviderKind}, runtime::extensions::ExtensionPreparationPort, }; -use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; use serde_json::{Value, json}; #[derive(Default)] @@ -104,7 +102,7 @@ impl PluginAccountAccess for Facts { &self, _: &ProviderAccountId, ) -> Result { - panic!("不允许读取凭据") + Err(AdminError::not_found("fixture credential is unavailable")) } async fn save( &self, @@ -142,14 +140,9 @@ impl PluginAccountAccess for Facts { } #[tokio::test] -async fn management_facts_are_minimal_bounded_and_separately_authorized() { - for (permission, email) in [ - (None, Some("private@example.invalid")), - (Some(Permission::Accounts), Some("private@example.invalid")), - (Some(Permission::Data), Some("private@example.invalid")), - (Some(Permission::Data), None), - ] { - let (cache, store, runtime) = super::setup_with_permissions( +async fn management_facts_validate_queries_without_permission_declarations() { + for email in [Some("private@example.invalid"), None] { + let (cache, store, runtime) = super::setup_with_contributions_and_restart_circuit( Contributions::from([crate::support::contribution( Capability::Management, vec![Stage::Management], @@ -157,7 +150,6 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { vec![], )]), Default::default(), - permission.into_iter().collect(), ) .await; let facts = Arc::new(Facts( @@ -170,12 +162,6 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { { let mut snapshot = store.snapshot.lock().unwrap(); let instance = &mut snapshot.instances[0]; - instance.grants = permission - .into_iter() - .map(|permission| PluginPermissionGrant { - permission: permission.as_str().into(), - }) - .collect(); instance.configuration = json!({ "management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]}, "data_queries":[ @@ -199,6 +185,7 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { &generation, &view.target, PluginManagementRequest { + headers: Vec::new(), method: "GET".into(), path: "facts".into(), query: String::new(), @@ -210,38 +197,29 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { .await .unwrap(); let result: Value = serde_json::from_slice(&response.body).unwrap(); - if permission == Some(Permission::Data) { - assert_eq!(facts.0.load(Ordering::SeqCst), 2, "{result}"); - assert_eq!( - result[0], - json!({"schema_version":1,"accounts":[{ + + assert_eq!(facts.0.load(Ordering::SeqCst), 2, "{result}"); + assert_eq!( + result[0], + json!({"schema_version":1,"accounts":[{ "account_id":"acct_facts","provider_id":"openai","group_ids":["grp_11111111111111111111111111111111"], "name":"private name","email":email, "enabled":true,"updated_at_ms":1767225600000i64 }],"next_cursor":"acct_facts"}) - ); - let page: gateway_plugin_sdk::call::data::AccountFactsPage = - serde_json::from_value(result[0].clone()).unwrap(); - assert_eq!(page.accounts[0].name, "private name"); - assert_eq!(page.accounts[0].email.as_deref(), email); - assert_eq!( - result[1], - json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":null, + ); + let page: gateway_plugin_sdk::call::data::AccountFactsPage = + serde_json::from_value(result[0].clone()).unwrap(); + assert_eq!(page.accounts[0].name, "private name"); + assert_eq!(page.accounts[0].email.as_deref(), email); + assert_eq!( + result[1], + json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":null, "windows":[{"key":"weekly","window_seconds":604800,"used_percent":null,"reset_at_ms":null}]}) - ); - for invalid in &result.as_array().unwrap()[2..] { - assert_eq!(invalid["error"], "invalid_input"); - } - } else { - assert_eq!(facts.0.load(Ordering::SeqCst), 0); - assert!( - result - .as_array() - .unwrap() - .iter() - .all(|result| result["error"] == "permission_denied") - ); + ); + for invalid in &result.as_array().unwrap()[2..] { + assert_eq!(invalid["error"], "invalid_input"); } + drop(generation); runtime.shutdown().await; super::wait_until_empty(cache.path()).await; @@ -249,13 +227,9 @@ async fn management_facts_are_minimal_bounded_and_separately_authorized() { } #[tokio::test] -async fn quota_refresh_requires_observation_permission_and_returns_only_facts() { - for permission in [ - None, - Some(Permission::Data), - Some(Permission::QuotaObservations), - ] { - let (cache, store, runtime) = super::setup_with_permissions( +async fn quota_refresh_is_available_without_permission_declarations() { + { + let (cache, store, runtime) = super::setup_with_contributions_and_restart_circuit( Contributions::from([crate::support::contribution( Capability::Management, vec![Stage::Management], @@ -263,7 +237,6 @@ async fn quota_refresh_requires_observation_permission_and_returns_only_facts() vec![], )]), Default::default(), - permission.into_iter().collect(), ) .await; let facts = Arc::new(Facts::default()); @@ -272,12 +245,6 @@ async fn quota_refresh_requires_observation_permission_and_returns_only_facts() { let mut snapshot = store.snapshot.lock().unwrap(); let instance = &mut snapshot.instances[0]; - instance.grants = permission - .into_iter() - .map(|permission| PluginPermissionGrant { - permission: permission.as_str().into(), - }) - .collect(); instance.configuration = json!({ "management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]}, "data_queries":[ @@ -304,6 +271,7 @@ async fn quota_refresh_requires_observation_permission_and_returns_only_facts() &generation, &view.target, PluginManagementRequest { + headers: Vec::new(), method: "GET".into(), path: "facts".into(), query: String::new(), @@ -315,34 +283,24 @@ async fn quota_refresh_requires_observation_permission_and_returns_only_facts() .await .unwrap(); let result: Value = serde_json::from_slice(&response.body).unwrap(); - if permission == Some(Permission::QuotaObservations) { - assert_eq!(facts.1.load(Ordering::SeqCst), 4); - assert_eq!(result[0]["observed_at_ms"], Value::Null); - assert_eq!( - result[1], - json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":1767225600000i64, + + assert_eq!(facts.1.load(Ordering::SeqCst), 4); + assert_eq!(result[0]["observed_at_ms"], Value::Null); + assert_eq!( + result[1], + json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":1767225600000i64, "windows":[{"key":"weekly","window_seconds":604800,"used_percent":17.5,"reset_at_ms":null}]}) - ); - for (index, code) in [ - (2, "invalid_input"), - (3, "rejected"), - (4, "invalid_input"), - (5, "fault"), - (6, "permission_denied"), - ] { - assert_eq!(result[index]["error"], code); - } - } else { - assert_eq!(facts.1.load(Ordering::SeqCst), 0); - for denied in &result.as_array().unwrap()[1..] { - assert_eq!(denied["error"], "permission_denied"); - } - if permission == Some(Permission::Data) { - assert_eq!(result[0]["account_id"], "acct_facts"); - } else { - assert_eq!(result[0]["error"], "permission_denied"); - } + ); + for (index, code) in [ + (2, "invalid_input"), + (3, "rejected"), + (4, "invalid_input"), + (5, "fault"), + (6, "rejected"), + ] { + assert_eq!(result[index]["error"], code); } + drop(generation); runtime.shutdown().await; super::wait_until_empty(cache.path()).await; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs index a2aa674c8..07655d291 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs @@ -1,4 +1,4 @@ -use crate::support::environment::{Environment, account_grant, mutation}; +use crate::support::environment::{Environment, mutation}; use gateway_admin::{ model::{ account_groups::{AccountGroupColor, CreateAccountGroup}, @@ -157,7 +157,12 @@ async fn wait_done(path: &std::path::Path, after: usize) -> Value { } }) .await - .expect("plugin reconciliation finished") + .unwrap_or_else(|error| { + panic!( + "plugin reconciliation did not finish: {error}; observations: {}", + std::fs::read_to_string(path).unwrap_or_default(), + ) + }) } #[tokio::test] @@ -170,7 +175,7 @@ async fn published_reconciliation_provisions_retries_imports_and_restores_withou let original = environment.account_group_with_account(&account).await; let marker = environment.directory.path().join("maintenance.jsonl"); let failed = environment.directory.path().join("failed-once"); - environment.install_plugin(json!({"maintenance_fixture":true, "maintenance_marker":marker, "maintenance_fail_once":failed}), vec![account_grant("groups"), account_grant("keys"), account_grant("data")]).await; + environment.install_plugin(json!({"maintenance_fixture":true, "maintenance_marker":marker, "maintenance_fail_once":failed})).await; let (runtime, core, resources, stop, task) = start(&environment).await; let first = wait_done(&marker, 0).await; assert!(failed.exists()); @@ -289,12 +294,7 @@ async fn resource_transactions_enforce_ownership_current_revision_and_idempotent let Some(environment) = Environment::create().await else { return; }; - environment - .install_plugin( - json!({}), - vec![account_grant("groups"), account_grant("keys")], - ) - .await; + environment.install_plugin(json!({})).await; let (runtime, core) = environment.runtime().await; let resources = gateway_admin::initialize_plugin_resources( environment.store.admin_ports().plugin_resources(), diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs index 11fe2a396..7192eee81 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs @@ -46,23 +46,11 @@ async fn setup_with_restart_circuit( async fn setup_with_contributions_and_restart_circuit( contributes: Contributions, restart_circuit: PluginRestartCircuitConfig, -) -> (tempfile::TempDir, Arc, PluginRuntime) { - setup_with_permissions(contributes, restart_circuit, vec![]).await -} - -async fn setup_with_permissions( - contributes: Contributions, - restart_circuit: PluginRestartCircuitConfig, - permissions: Vec, ) -> (tempfile::TempDir, Arc, PluginRuntime) { let cache = tempfile::tempdir().unwrap(); let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) .inspect( - crate::support::package_with_contributions( - crate::support::worker(), - permissions, - contributes, - ), + crate::support::package_with_contributions(crate::support::worker(), contributes), None, ) .await @@ -75,7 +63,7 @@ async fn setup_with_permissions( trusted_process: true, configuration: serde_json::json!({}), secrets: BTreeMap::new(), - grants: vec![], + bindings: vec![], revision: Revision::new(1).unwrap(), }; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs index 2ab1523ba..3ca0342c2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs @@ -5,7 +5,6 @@ use gateway_admin::{ Revision, plugins::instances::{ PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot, - PluginPermissionGrant, }, }, ports::plugins::PluginPackageInspector, @@ -31,17 +30,13 @@ use gateway_core::{ use gateway_plugin_runtime::{ PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, }; -use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Contributions, Stage, call::observation::EventKind}; use crate::support::store::Store; -const REQUEST_LIFECYCLE_CONTRIBUTION: &str = "test.example.requestLifecycle"; -const USAGE_CONTRIBUTION: &str = "test.example.usage"; -const WEB_SOCKET_OBSERVER_CONTRIBUTION: &str = "test.example.webSocketObserver"; - -fn binding(contribution: &str, order: i32) -> PluginCapabilityBinding { +fn binding(event: EventKind, order: i32) -> PluginCapabilityBinding { PluginCapabilityBinding { - contribution: contribution.into(), + contribution: "test.example.observer".into(), stage: "observation".into(), order, failure_policy: PluginFailurePolicy::Observe, @@ -49,6 +44,13 @@ fn binding(contribution: &str, order: i32) -> PluginCapabilityBinding { account_group_ids: vec![], provider_ids: vec!["openai".into()], models: vec!["gpt-observed".into()], + event: Some( + serde_json::to_value(event) + .unwrap() + .as_str() + .unwrap() + .into(), + ), identity_bindings: vec![], } } @@ -56,64 +58,44 @@ fn binding(contribution: &str, order: i32) -> PluginCapabilityBinding { struct InstanceFixture { id: String, configuration: serde_json::Value, - grants: Vec, - bindings: Vec, -} -fn grant(permission: Permission) -> PluginPermissionGrant { - PluginPermissionGrant { - permission: permission.as_str().to_owned(), - } + bindings: Vec, } async fn setup( instances: Vec<(&str, serde_json::Value, Vec)>, rpc_limits: RpcLimits, ) -> (tempfile::TempDir, Arc, PluginRuntime) { - setup_with_permissions( + setup_instances( instances .into_iter() .map(|(id, configuration, bindings)| InstanceFixture { id: id.to_owned(), configuration, - grants: vec![], + bindings, }) .collect(), - vec![], rpc_limits, ) .await } -async fn setup_with_permissions( +async fn setup_instances( instances: Vec, - manifest_permissions: Vec, + rpc_limits: RpcLimits, ) -> (tempfile::TempDir, Arc, PluginRuntime) { let cache = tempfile::tempdir().unwrap(); - let contributes = Contributions::from([ - crate::support::contribution( - Capability::RequestLifecycle, - vec![Stage::Observation], - vec![], - vec![], - ), - crate::support::contribution(Capability::Usage, vec![Stage::Observation], vec![], vec![]), - crate::support::contribution( - Capability::WebSocketObserver, - vec![Stage::Observation], - vec![], - vec![], - ), - ]); + let contributes = Contributions::from([crate::support::contribution( + Capability::Observer, + vec![Stage::Observation], + vec![], + vec![], + )]); let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) .inspect( - crate::support::package_with_contributions( - crate::support::worker(), - manifest_permissions, - contributes, - ), + crate::support::package_with_contributions(crate::support::worker(), contributes), None, ) .await @@ -129,7 +111,7 @@ async fn setup_with_permissions( trusted_process: true, configuration: fixture.configuration, secrets: BTreeMap::new(), - grants: fixture.grants, + bindings: fixture.bindings, revision: Revision::new(1).unwrap(), }) @@ -235,14 +217,14 @@ async fn frozen_plan_applies_scope_order_and_instance_deduplication() { ( "late", serde_json::json!({"observation_label":"late","observation_marker":marker}), - vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 20)], + vec![binding(EventKind::RequestCompleted, 20)], ), ( "early", serde_json::json!({"observation_label":"early","observation_marker":marker}), vec![ - binding(REQUEST_LIFECYCLE_CONTRIBUTION, 10), - binding(USAGE_CONTRIBUTION, 10), + binding(EventKind::RequestCompleted, 10), + binding(EventKind::WebSocketResponse, 10), ], ), ], @@ -265,14 +247,14 @@ async fn frozen_plan_applies_scope_order_and_instance_deduplication() { assert_eq!( lines.len(), 2, - "lifecycle and usage observations on one instance share one call" + "one completed event carries both terminal and usage facts" ); assert_eq!(lines[0]["label"], "early"); assert!(lines[0]["observation"]["terminal"].is_object()); assert_eq!(lines[0]["observation"]["usage"]["total_tokens"], 18); assert_eq!(lines[1]["label"], "late"); assert!(lines[1]["observation"]["terminal"].is_object()); - assert!(lines[1]["observation"].get("usage").is_none()); + assert_eq!(lines[1]["observation"]["usage"]["total_tokens"], 18); plan.dispatch( generation.clone(), @@ -292,11 +274,11 @@ async fn frozen_plan_applies_scope_order_and_instance_deduplication() { } #[tokio::test] -async fn websocket_observer_preserves_order_generation_and_permission_boundaries() { +async fn websocket_observer_preserves_order_generation_and_complete_payload() { let marker_directory = tempfile::tempdir().unwrap(); let hidden = marker_directory.path().join("hidden.jsonl"); let readable = marker_directory.path().join("readable.jsonl"); - let (cache, _store, runtime) = setup_with_permissions( + let (cache, _store, runtime) = setup_instances( vec![ InstanceFixture { id: "hidden".into(), @@ -304,8 +286,8 @@ async fn websocket_observer_preserves_order_generation_and_permission_boundaries "observation_label":"hidden", "websocket_observation_marker":hidden, }), - grants: vec![], - bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1)], + + bindings: vec![binding(EventKind::WebSocketResponse, 1)], }, InstanceFixture { id: "readable".into(), @@ -313,11 +295,10 @@ async fn websocket_observer_preserves_order_generation_and_permission_boundaries "observation_label":"readable", "websocket_observation_marker":readable, }), - grants: vec![grant(Permission::Requests)], - bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 2)], + + bindings: vec![binding(EventKind::WebSocketResponse, 2)], }, ], - vec![Permission::Requests], RpcLimits::default(), ) .await; @@ -358,15 +339,10 @@ async fn websocket_observer_preserves_order_generation_and_permission_boundaries vec![1, 2] ); assert!(hidden.iter().all(|line| { - line["payload_bytes"] == 0 - && line["observation"]["payload_included"] == false - && line["observation"].get("event_type").is_none() + line["payload_bytes"].as_u64().unwrap() > 0 + && line["observation"]["payload_included"] == true + && line["observation"]["event_type"] == "response.output_text.delta" && line["observation"]["account_id"] == "acct_observed" - && !line.to_string().contains("client-key-observed") - && !line - .to_string() - .contains("grp_11111111111111111111111111111111") - && !line.to_string().contains("secret") })); assert_eq!( readable @@ -406,7 +382,7 @@ async fn websocket_observer_bounds_event_count_and_payload_bytes_without_blockin maximum_calls: 1, ..RpcLimits::default() }; - let (count_cache, _count_store, count_runtime) = setup_with_permissions( + let (count_cache, _count_store, count_runtime) = setup_instances( vec![InstanceFixture { id: "count".into(), configuration: serde_json::json!({ @@ -414,10 +390,9 @@ async fn websocket_observer_bounds_event_count_and_payload_bytes_without_blockin "websocket_observation_marker":count_completed, "websocket_observation_delay_ms":150, }), - grants: vec![grant(Permission::Requests)], - bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1)], + + bindings: vec![binding(EventKind::WebSocketResponse, 1)], }], - vec![Permission::Requests], limits, ) .await; @@ -467,7 +442,7 @@ async fn websocket_observer_bounds_event_count_and_payload_bytes_without_blockin let byte_started = marker_directory.path().join("byte-started.jsonl"); let fault_completed = marker_directory.path().join("fault-completed.jsonl"); let healthy_completed = marker_directory.path().join("healthy-completed.jsonl"); - let (byte_cache, _byte_store, byte_runtime) = setup_with_permissions( + let (byte_cache, _byte_store, byte_runtime) = setup_instances( vec![ InstanceFixture { id: "fault".into(), @@ -477,19 +452,18 @@ async fn websocket_observer_bounds_event_count_and_payload_bytes_without_blockin "websocket_observation_delay_ms":150, "websocket_observation_fault":true, }), - grants: vec![grant(Permission::Requests)], - bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1)], + + bindings: vec![binding(EventKind::WebSocketResponse, 1)], }, InstanceFixture { id: "healthy".into(), configuration: serde_json::json!({ "websocket_observation_marker":healthy_completed, }), - grants: vec![grant(Permission::Requests)], - bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 2)], + + bindings: vec![binding(EventKind::WebSocketResponse, 2)], }, ], - vec![Permission::Requests], RpcLimits::default(), ) .await; @@ -548,7 +522,7 @@ async fn observer_failure_continues_the_plan_and_timeout_is_bounded() { "observation_marker":completed, "observation_fault":true, }), - vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1)], + vec![binding(EventKind::RequestCompleted, 1)], ), ( "timeout", @@ -558,7 +532,7 @@ async fn observer_failure_continues_the_plan_and_timeout_is_bounded() { "observation_marker":completed, "observation_delay_ms":2500, }), - vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 2)], + vec![binding(EventKind::RequestCompleted, 2)], ), ], RpcLimits::default(), @@ -591,26 +565,36 @@ async fn observer_failure_continues_the_plan_and_timeout_is_bounded() { #[tokio::test] async fn invalid_observer_stage_policy_and_mixed_order_are_rejected_before_publish() { - let mut invalid_stage = binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1); + let mut invalid_stage = binding(EventKind::RequestCompleted, 1); invalid_stage.stage = "routing".into(); - let mut invalid_policy = binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1); + let mut invalid_policy = binding(EventKind::RequestCompleted, 1); invalid_policy.failure_policy = PluginFailurePolicy::Reject; - let mut invalid_websocket_stage = binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1); + let mut invalid_websocket_stage = binding(EventKind::WebSocketResponse, 1); invalid_websocket_stage.stage = "stream".into(); - let mut invalid_websocket_policy = binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1); + let mut invalid_websocket_policy = binding(EventKind::WebSocketResponse, 1); invalid_websocket_policy.failure_policy = PluginFailurePolicy::Delegate; + let mut missing_event = binding(EventKind::RequestCompleted, 1); + missing_event.event = None; + let mut unknown_event = binding(EventKind::RequestCompleted, 1); + unknown_event.event = Some("unknown".into()); for bindings in [ + vec![missing_event], + vec![unknown_event], + vec![ + binding(EventKind::RequestCompleted, 1), + binding(EventKind::RequestCompleted, 1), + ], vec![invalid_stage], vec![invalid_policy], vec![invalid_websocket_stage], vec![invalid_websocket_policy], vec![ - binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1), - binding(USAGE_CONTRIBUTION, 2), + binding(EventKind::RequestCompleted, 1), + binding(EventKind::RequestCompleted, 2), ], vec![ - binding(USAGE_CONTRIBUTION, 1), - binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 2), + binding(EventKind::RequestCompleted, 1), + binding(EventKind::WebSocketResponse, 2), ], ] { let (_cache, store, runtime) = setup( @@ -633,7 +617,7 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier() let marker = marker_directory.path().join("scoped-observations.jsonl"); let matching_group = "grp_11111111111111111111111111111111"; let other_group = "grp_22222222222222222222222222222222"; - let mut scoped = binding(USAGE_CONTRIBUTION, 1); + let mut scoped = binding(EventKind::RequestCompleted, 1); scoped.client_key_ids = vec!["client-key-observed".into()]; scoped.account_group_ids = vec![matching_group.into()]; scoped.provider_ids.clear(); @@ -663,7 +647,7 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier() ); let lines = wait_for_lines(&marker, 1).await; assert_eq!(lines[0]["observation"]["request_id"], "req_scope_match"); - assert!(lines[0]["observation"].get("terminal").is_none()); + assert_eq!(lines[0]["observation"]["terminal"]["outcome"], "rejected"); assert_eq!( lines[0]["observation"]["usage"]["failure"]["outcome"], "rejected" @@ -710,14 +694,14 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier() } #[tokio::test] -async fn usage_observation_contains_final_cost_timings_and_safe_failure_only() { +async fn completed_observation_contains_terminal_cost_timings_and_failure() { let marker_directory = tempfile::tempdir().unwrap(); let marker = marker_directory.path().join("usage-observations.jsonl"); let (cache, _store, runtime) = setup( vec![( "usage", serde_json::json!({"observation_label":"usage","observation_marker":marker}), - vec![binding(USAGE_CONTRIBUTION, 1)], + vec![binding(EventKind::RequestCompleted, 1)], )], RpcLimits::default(), ) @@ -751,7 +735,7 @@ async fn usage_observation_contains_final_cost_timings_and_safe_failure_only() { ); let lines = wait_for_lines(&marker, 1).await; let observed = &lines[0]["observation"]; - assert!(observed.get("terminal").is_none()); + assert_eq!(observed["terminal"]["outcome"], "failed"); assert_eq!(observed["usage"]["cost"]["status"], "known"); assert_eq!(observed["usage"]["cost"]["source"], "provider_reported"); assert_eq!(observed["usage"]["cost"]["total"]["amount"], "0.0123"); @@ -778,11 +762,11 @@ async fn usage_observation_contains_final_cost_timings_and_safe_failure_only() { #[tokio::test] async fn invalid_client_key_and_group_scopes_are_rejected_before_publish() { - let mut duplicate_keys = binding(USAGE_CONTRIBUTION, 1); + let mut duplicate_keys = binding(EventKind::RequestCompleted, 1); duplicate_keys.client_key_ids = vec!["client-key".into(), "client-key".into()]; - let mut invalid_group = binding(USAGE_CONTRIBUTION, 1); + let mut invalid_group = binding(EventKind::RequestCompleted, 1); invalid_group.account_group_ids = vec!["not-a-group".into()]; - let mut duplicate_groups = binding(USAGE_CONTRIBUTION, 1); + let mut duplicate_groups = binding(EventKind::RequestCompleted, 1); duplicate_groups.account_group_ids = vec![ "grp_33333333333333333333333333333333".into(), "grp_33333333333333333333333333333333".into(), @@ -824,7 +808,7 @@ async fn observer_backpressure_drops_excess_and_inflight_dispatch_keeps_generati "observation_marker":completed, "observation_delay_ms":200, }), - vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1)], + vec![binding(EventKind::RequestCompleted, 1)], )], limits, ) @@ -858,3 +842,73 @@ async fn observer_backpressure_drops_excess_and_inflight_dispatch_keeps_generati ); super::wait_until_empty(cache.path()).await; } + +#[tokio::test] +async fn one_observer_matches_completed_and_websocket_scopes_independently() { + let markers = tempfile::tempdir().unwrap(); + let completed = markers.path().join("completed.jsonl"); + let websocket = markers.path().join("websocket.jsonl"); + let mut completed_binding = binding(EventKind::RequestCompleted, 0); + completed_binding.models = vec!["gpt-completed".into()]; + let mut websocket_binding = binding(EventKind::WebSocketResponse, 0); + websocket_binding.models = vec!["gpt-stream".into()]; + let (cache, _store, runtime) = setup( + vec![( + "observer", + serde_json::json!({ + "observation_marker": completed, "websocket_observation_marker": websocket, + }), + vec![completed_binding, websocket_binding], + )], + RpcLimits::default(), + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + for (id, model) in [ + ("req_matched", "gpt-completed"), + ("req_missed", "gpt-stream"), + ] { + plan.dispatch( + generation.clone(), + observation(id, RequestObservationOutcome::Succeeded) + .with_provider(ProviderKind::new("openai").unwrap()) + .with_requested_model(PublicModelId::new(model).unwrap()), + ); + } + for (id, model) in [ + ("req_matched_ws", "gpt-stream"), + ("req_missed_ws", "gpt-completed"), + ] { + plan.dispatch_websocket_response( + generation.clone(), + websocket_observation(id, 1, serde_json::json!({"delta":"test"})) + .with_requested_model(PublicModelId::new(model).unwrap()), + ); + } + assert_eq!( + wait_for_lines(&completed, 1).await[0]["observation"]["request_id"], + "req_matched" + ); + assert_eq!( + wait_for_lines(&websocket, 1).await[0]["observation"]["request_id"], + "req_matched_ws" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(wait_for_lines(&completed, 1).await.len(), 1); + assert_eq!(wait_for_lines(&websocket, 1).await.len(), 1); + for id in ["req_matched_ws", "req_missed_ws"] { + plan.dispatch( + generation.clone(), + observation(id, RequestObservationOutcome::Succeeded), + ); + } + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs new file mode 100644 index 000000000..7baa8b191 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs @@ -0,0 +1,191 @@ +use super::*; +use futures::StreamExt; +use gateway_core::{ + engine::provider::{ + ProviderCallMetadata, ProviderSelectionObservation, ProviderStream, + execute_attempt_middleware, + }, + event::{ + FinishReason, GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, + ProviderResponseMetadata, ProviderResponseObservation, ProviderResponseTimings, + ResponseMeta, + }, + metering::{ + CalculatedCostAmounts, CalculatedCostBreakdown, CalculatedCostRates, Decimal, Money, + ProviderReportedCost, Usage, + }, + operation::{ExtensionSessionOwner, ProviderSessionState}, + routing::UpstreamModelId, + upstream::{OpaqueUpstreamValue, UpstreamTransport}, +}; + +#[tokio::test] +async fn facts_are_readable_without_replacing_or_duplicating_the_original_execution() { + let records = tempfile::tempdir().unwrap(); + let marker = records.path().join("facts.jsonl"); + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Attempt], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let (_cache, runtime) = setup_package(vec![InstanceFixture { + id: "facts-reader", + configuration: serde_json::json!({"mode":"facts", "attempt":true, "facts_marker":marker}), + bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, "attempt", 0, PluginFailurePolicy::Reject)], + }], package).await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let metadata = ProviderCallMetadata::new( + ProviderKind::new("openai").unwrap(), + UpstreamModelId::new("native-model").unwrap(), + ProviderAccountId::new("acct_original").unwrap(), + UpstreamTransport::new("http").unwrap(), + ) + .with_upstream_request_id(OpaqueUpstreamValue::new("upstream-original")) + .with_selection_observation(ProviderSelectionObservation::new(17, None)); + let amount = Money::new( + Decimal::from_scaled(123).unwrap(), + gateway_core::metering::CurrencyCode::new("USD").unwrap(), + ); + let breakdown = CalculatedCostBreakdown::new( + CalculatedCostAmounts::new(amount, amount, amount, amount, amount, amount), + CalculatedCostRates::new(amount, amount, amount, amount), + Some("priority".into()), + 125, + ) + .with_long_context_billing(true); + let started = ProviderEvent::canonical(GatewayEvent::Started(ResponseMeta::new( + "response-original", + "native-model", + ))); + let mut completed = ProviderEvent::canonical_with_wire( + vec![ + GatewayEvent::Usage(Usage { + input_tokens: Some(7), + output_tokens: Some(2), + ..Usage::default() + }), + GatewayEvent::CalculatedCost(breakdown.calculated_cost()), + GatewayEvent::ProviderCost(ProviderReportedCost::from_usd_ticks(456).unwrap()), + GatewayEvent::Completed( + ResponseMeta::new("response-original", "native-model") + .with_finish_reason(FinishReason::Stop), + ), + ], + ProtocolWireEvent::raw_json("openai", Bytes::from_static(br#"{ "output": [] }"#)).unwrap(), + ); + completed.attach_observation( + ProviderResponseObservation::new(UpstreamTransport::new("http").unwrap()) + .with_status_code(200) + .with_request_id(OpaqueUpstreamValue::new("response-request-id")) + .with_service_tier_if_valid("priority") + .with_upstream_response_model_if_valid("returned-model") + .with_client_headers(vec![ProviderResponseHeader::new( + "set-cookie", + Bytes::from_static(b"fixture=secret"), + )]) + .with_provider_metadata( + ProviderResponseMetadata::new("{\"private_field\":\"kept\"}".into()).unwrap(), + ) + .with_timings(ProviderResponseTimings { + headers_ms: Some(19), + ..Default::default() + }), + ); + completed.attach_session_update( + ProviderSessionState::new( + "openai", + serde_json::json!({"cursor":"private-cursor"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_extension_owner(ExtensionSessionOwner { + instance_id: "owner".into(), + contribution_id: "owner.adapter".into(), + adapter_id: "adapter".into(), + generation: 7, + incarnation: "incarnation".into(), + connection_local: true, + }), + ); + let mut stream = execute_attempt_middleware( + Some(&plan), + attempt_middleware_context(Arc::default()), + operation(), + ClientTransport::HttpJson, + Box::new(move |_, _| { + Box::pin(async move { + Ok(ProviderStream::new( + metadata, + futures::stream::iter([Ok(started), Ok(completed)]), + (), + )) + }) + }), + ) + .await + .unwrap(); + assert_eq!( + stream.metadata().provider_account_id().as_str(), + "acct_original" + ); + let mut events = Vec::new(); + while let Some(event) = stream.next().await { + events.push(event.unwrap()); + } + assert_eq!(events.len(), 2); + let costs = events + .iter() + .flat_map(ProviderEvent::canonical_facts) + .filter(|fact| { + matches!( + fact, + GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) + ) + }) + .count(); + assert_eq!( + costs, 2, + "reading and modifying snapshots must not change original cost facts" + ); + let snapshots = marker_lines(&marker, 3).await; + assert_eq!(snapshots[0]["provider_account_id"], "acct_original"); + assert_eq!(snapshots[0]["upstream_request_id"], "upstream-original"); + assert_eq!( + snapshots[0]["selection_observation"]["account_selection_wait_ms"], + 17 + ); + assert_eq!(snapshots[1]["facts"][0]["type"], "started"); + let facts = &snapshots[2]["host"]; + assert_eq!(facts["costs"][0]["total"]["amount"], "0.0000000123"); + assert_eq!( + facts["costs"][0]["breakdown"]["long_context_billing_applied"], + true + ); + assert_eq!(facts["costs"][1]["total"]["amount"], "0.0000000456"); + assert_eq!( + facts["observation"]["client_headers"][0]["value"], + serde_json::json!(b"fixture=secret".to_vec()) + ); + assert_eq!( + facts["observation"]["provider_metadata"], + "{\"private_field\":\"kept\"}" + ); + assert_eq!(facts["observation"]["timings"]["headers_ms"], 19); + assert_eq!( + facts["session_update"]["payload"]["cursor"], + "private-cursor" + ); + assert_eq!(facts["session_update"]["extension_owner"]["generation"], 7); + assert_eq!(facts["middleware_transformed"], false); + assert!(facts["middleware_origin_wire"].is_null()); + drop(stream); + runtime.shutdown().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/dispatch.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/dispatch.rs new file mode 100644 index 000000000..0672848d7 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/dispatch.rs @@ -0,0 +1,477 @@ +use super::*; +use http_body::{Body, Frame}; +use std::{ + pin::Pin, + sync::Mutex, + task::{Context, Poll}, +}; + +struct Dispatcher { + settings: Arc>>, + calls: Arc>>, + entered: Arc, + pending: bool, + dropped: Arc, +} + +struct Pending(Arc); +impl Drop for Pending { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +struct CheckedBody(core::Body, CancellationToken); +impl Body for CheckedBody { + type Data = Bytes; + type Error = Box; + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + assert!( + !self.1.is_cancelled(), + "RPC End must not cancel a transferred response body" + ); + Pin::new(&mut self.0).poll_frame(cx) + } +} + +impl core::Dispatcher for Dispatcher { + fn dispatch( + &self, + context: core::Context, + request: core::Request, + ) -> BoxFuture<'static, Result> { + self.calls.lock().unwrap().push(context.clone()); + if let Some(settings) = request.extensions().get::().cloned() { + self.settings.lock().unwrap().push(settings); + } + let entered = self.entered.clone(); + let dropped = self.dropped.clone(); + let pending = self.pending; + Box::pin(async move { + if pending { + let _pending = Pending(dropped); + entered.notify_one(); + return std::future::pending().await; + } + let cancellation = context.cancellation.clone(); + let next = compose(Vec::new(), move |request: core::Request| { + Box::pin(async move { + let (parts, body) = request.into_parts(); + let mut response = + core::Response::new(CheckedBody(body, cancellation).boxed_unsync()); + *response.headers_mut() = parts.headers; + response.extensions_mut().insert(42_u32); + Ok(response) + }) + }); + context + .plan + .clone() + .unwrap() + .handle_http(context, request, next) + .await + }) + } +} + +async fn setup( + mode: &str, +) -> ( + tempfile::TempDir, + PluginRuntime, + ExtensionSetReference, + gateway_core::engine::middleware::FrozenMiddlewarePlan, +) { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Http], + vec!["http".into()], + vec!["http".into()], + )]), + ); + let (cache, runtime) = setup_package( + ["dispatch-a", "dispatch-b"] + .into_iter() + .enumerate() + .map(|(index, id)| InstanceFixture { + id, + configuration: serde_json::json!({"http":true,"mode":mode}), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "http", + index as i32, + PluginFailurePolicy::Reject, + )], + }) + .collect(), + package, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + (cache, runtime, generation, plan) +} + +fn root(plan: &gateway_core::engine::middleware::FrozenMiddlewarePlan) -> core::Context { + core::Context { + plugin_instance_id: None, + request_id: "root-request".into(), + call_id: "root-call".into(), + parent_call_id: None, + cancellation: CancellationToken::new(), + extensions: Default::default(), + plan: Some(plan.clone()), + } +} + +#[tokio::test] +async fn sequential_streamed_children_release_upload_capacity_in_one_call() { + check_http_resources("sequential_uploads", 40).await; +} + +#[tokio::test] +async fn upload_capacity_tracks_active_writers_and_closing_a_reader_unblocks_writes() { + check_http_resources("upload_resources", 1).await; +} + +async fn check_http_resources(mode: &str, children: usize) { + let (_cache, runtime, generation, plan) = http_plugin(mode).await; + let calls = Arc::new(Mutex::new(Vec::new())); + let dispatcher: Arc = Arc::new(Dispatcher { + settings: Arc::default(), + calls: calls.clone(), + entered: Arc::default(), + pending: false, + dropped: Arc::default(), + }); + runtime.bind_http(&dispatcher).unwrap(); + let response = plan + .handle_http( + root(&plan), + core::Request::new(core::empty_body()), + compose(Vec::new(), |request: core::Request| { + Box::pin(async { Ok(core::Response::new(request.into_body())) }) + }), + ) + .await + .unwrap(); + assert!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty() + ); + assert_eq!(calls.lock().unwrap().len(), children); + drop(dispatcher); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn closing_body_preserves_response_parts_for_returning_a_replacement_body() { + check_response_parts("close_response_body", "replacement").await; +} + +#[tokio::test] +async fn returning_a_response_after_eof_preserves_parts_with_an_empty_body() { + check_response_parts("read_response_body", "").await; +} + +async fn check_response_parts(mode: &str, expected: &str) { + let (_cache, runtime, generation, plan) = http_plugin(mode).await; + let dropped = Arc::new(AtomicUsize::new(0)); + let lifetime = Pending(dropped.clone()); + let read_to_eof = mode == "read_response_body"; + let response = plan + .handle_http( + root(&plan), + core::Request::new(core::empty_body()), + compose(Vec::new(), move |_| { + Box::pin(async move { + let body = StreamBody::new(futures::stream::once(async move { + let _lifetime = lifetime; + if !read_to_eof { + std::future::pending::<()>().await; + } + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"original"))) + })) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let mut response = core::Response::new(body); + *response.status_mut() = ::http::StatusCode::ACCEPTED; + response + .headers_mut() + .insert("x-original", "kept".parse().unwrap()); + response.extensions_mut().insert(42_u32); + Ok(response) + }) + }), + ) + .await + .unwrap(); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + assert_eq!(response.status(), ::http::StatusCode::ACCEPTED); + assert_eq!(response.headers()["x-original"], "kept"); + assert_eq!(response.extensions().get::(), Some(&42)); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + expected + ); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn active_http_children_share_stream_resources_and_skip_all_ancestors() { + for mode in ["dispatch", "dispatch_upload"] { + let (_cache, runtime, generation, plan) = setup(mode).await; + let calls = Arc::new(Mutex::new(Vec::new())); + let dispatcher: Arc = Arc::new(Dispatcher { + settings: Arc::default(), + calls: calls.clone(), + entered: Arc::default(), + pending: false, + dropped: Arc::default(), + }); + runtime.bind_http(&dispatcher).unwrap(); + let context = root(&plan); + let cancellation = context.cancellation.clone(); + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let expected = vec![b'a'; 2 * 1024 * 1024]; + let mut trailers = ::http::HeaderMap::new(); + trailers.append("x-trailer", "one".parse().unwrap()); + trailers.append("x-trailer", "two".parse().unwrap()); + let mut frames = vec![ + Frame::data(Bytes::from(expected.clone())), + Frame::trailers(trailers), + ] + .into_iter(); + let body = StreamBody::new(futures::stream::poll_fn(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + Poll::Ready(frames.next().map(Ok::<_, std::io::Error>)) + })) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let request = ::http::Request::builder() + .uri("/same/path?raw=%2f%FF") + .header("x-duplicate", "one") + .header("x-duplicate", "two") + .body(body) + .unwrap(); + let response = plan + .handle_http( + context, + request, + compose(Vec::new(), |_| { + Box::pin(async { panic!("dispatch replaces next") }) + }), + ) + .await + .unwrap(); + if mode == "dispatch" { + assert_eq!(reads.load(Ordering::SeqCst), 0); + } + assert_eq!( + response + .headers() + .get_all("x-dispatch-instance") + .iter() + .count(), + 2 + ); + assert_eq!(response.headers().get_all("x-duplicate").iter().count(), 2); + assert_eq!(response.extensions().get::(), Some(&42)); + let collected = response.into_body().collect().await.unwrap(); + assert_eq!( + collected + .trailers() + .unwrap() + .get_all("x-trailer") + .iter() + .count(), + 2 + ); + assert_eq!( + collected.to_bytes().as_ref(), + if mode == "dispatch" { + expected + } else { + expected.to_ascii_uppercase() + } + ); + assert!(!cancellation.is_cancelled()); + { + let observed = calls.lock().unwrap(); + assert_eq!(observed.len(), 2); + assert_eq!(observed[0].parent_call_id.as_deref(), Some("root-call")); + assert_eq!( + observed[1].parent_call_id.as_deref(), + Some(observed[0].call_id.as_str()) + ); + assert_ne!(observed[0].call_id, observed[1].call_id); + assert_eq!(observed[1].request_id, "root-request"); + assert!(observed[1].extensions.contains("dispatch-a")); + assert!(observed[1].extensions.contains("dispatch-b")); + } + drop(dispatcher); + drop(calls); + drop(plan); + drop(generation); + runtime.shutdown().await; + } +} + +#[tokio::test] +async fn cancelling_a_parent_drops_its_active_http_child() { + let (_cache, runtime, generation, plan) = setup("dispatch").await; + let entered = Arc::new(tokio::sync::Notify::new()); + let dropped = Arc::new(AtomicUsize::new(0)); + let dispatcher: Arc = Arc::new(Dispatcher { + settings: Arc::default(), + calls: Arc::default(), + entered: entered.clone(), + pending: true, + dropped: dropped.clone(), + }); + runtime.bind_http(&dispatcher).unwrap(); + let context = root(&plan); + let cancellation = context.cancellation.clone(); + let request = core::Request::new(core::empty_body()); + let task = tokio::spawn(plan.handle_http( + context, + request, + compose(Vec::new(), |_| { + Box::pin(async { panic!("unexpected next") }) + }), + )); + tokio::time::timeout(Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + cancellation.cancel(); + assert!(task.await.unwrap().is_err()); + tokio::time::timeout(Duration::from_secs(3), async { + while dropped.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + drop(dispatcher); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn active_http_children_inherit_settings_and_preserve_override_sources() { + use gateway_core::{routing::RuntimeSnapshot, settings::RequestSettings}; + let (_cache, runtime, generation, plan) = setup("settings").await; + let settings = Arc::new(Mutex::new(Vec::new())); + let dispatcher: Arc = Arc::new(Dispatcher { + settings: settings.clone(), + calls: Arc::default(), + entered: Arc::default(), + pending: false, + dropped: Arc::default(), + }); + runtime.bind_http(&dispatcher).unwrap(); + let baseline = RequestSettings::new(Arc::new( + RuntimeSnapshot::new( + ConfigRevision::new(1).unwrap(), + gateway_core::settings::SettingsValues::new( + 1, + 10, + "smart", + Default::default(), + None, + None, + ), + vec![], + vec![], + vec![], + ) + .unwrap(), + )) + .with_http_timeout(Some(60_000)); + let mut request = core::Request::new(core::empty_body()); + request.extensions_mut().insert(core::Settings { + timeout: Some(Duration::from_secs(60)), + runtime: Some(baseline.clone()), + }); + plan.handle_http( + root(&plan), + request, + compose(Vec::new(), |_| { + Box::pin(async { panic!("explicit dispatch replaces next") }) + }), + ) + .await + .unwrap() + .into_body() + .collect() + .await + .unwrap(); + { + let observed = settings.lock().unwrap(); + assert_eq!(observed.len(), 2); + assert_eq!(observed[0].timeout, Some(Duration::from_secs(90))); + assert_eq!(observed[1].timeout, None); + let effective = observed[1].runtime.as_ref().unwrap(); + let final_settings = serde_json::to_value(effective.values()).unwrap(); + assert_eq!( + final_settings["responses_max_decompressed_body_bytes"], + 8192 + ); + assert_eq!(final_settings["request_interval_ms"], 0); + assert!(final_settings["min_codex_cli_version"].is_null()); + let sources = effective.inspect(); + assert_eq!( + sources["overrides"]["min_codex_cli_version"]["instance_id"], + "dispatch-b" + ); + assert_eq!( + sources["overrides"]["request_interval_ms"]["instance_id"], + "dispatch-a" + ); + assert!( + sources["overrides"]["min_codex_cli_version"]["order"] + .as_u64() + .unwrap() + > sources["overrides"]["request_interval_ms"]["order"] + .as_u64() + .unwrap() + ); + assert_eq!(sources["http_timeout"]["input_ms"], 60_000); + assert_eq!( + sources["http_timeout"]["change"]["instance_id"], + "dispatch-b" + ); + assert!(sources["http_timeout"]["change"]["value"].is_null()); + assert_eq!( + sources["host"]["responses_max_decompressed_body_bytes"], + 64 * 1024 * 1024 + ); + assert!( + baseline.inspect()["overrides"] + .as_object() + .unwrap() + .is_empty() + ); + } + drop(dispatcher); + drop(plan); + drop(generation); + runtime.shutdown().await; +} 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 new file mode 100644 index 000000000..d2965ea48 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/mod.rs @@ -0,0 +1,649 @@ +mod dispatch; +mod websocket; + +use super::*; +use gateway_core::middleware::{compose, http as core}; +use http_body::Frame; +use http_body_util::{BodyExt as _, StreamBody}; + +async fn http_plugin( + mode: &str, +) -> ( + tempfile::TempDir, + PluginRuntime, + ExtensionSetReference, + gateway_core::engine::middleware::FrozenMiddlewarePlan, +) { + http_plugin_with_limits(mode, RpcLimits::default()).await +} + +async fn http_plugin_with_limits( + mode: &str, + limits: RpcLimits, +) -> ( + tempfile::TempDir, + PluginRuntime, + ExtensionSetReference, + gateway_core::engine::middleware::FrozenMiddlewarePlan, +) { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Http], + vec!["http".into()], + vec!["http".into()], + )]), + ); + let (cache, runtime) = setup_package_with_limits( + vec![InstanceFixture { + id: "http-plugin", + configuration: serde_json::json!({"http":true,"mode":mode}), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "http", + 0, + PluginFailurePolicy::Reject, + )], + }], + package, + limits, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + assert!(plan.has_http()); + (cache, runtime, generation, plan) +} + +#[tokio::test] +async fn real_http_plugin_rewrites_full_request_without_eager_body_reads() { + let (cache, runtime, generation, plan) = http_plugin("passthrough").await; + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let mut frames = vec![Frame::data(Bytes::from_static(b"opaque request"))].into_iter(); + let body = StreamBody::new(futures::stream::poll_fn(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + std::task::Poll::Ready(frames.next().map(Ok::<_, std::io::Error>)) + })) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let mut request = ::http::Request::builder() + .uri("/custom/route") + .header("authorization", "Bearer fixture-secret") + .header("x-duplicate", "one") + .header("x-duplicate", "two") + .body(body) + .unwrap(); + request.extensions_mut().insert(core::Settings { + runtime: None, + timeout: Some(Duration::from_secs(1)), + }); + request.extensions_mut().insert(123_u32); + let next = compose(Vec::new(), |request: core::Request| { + Box::pin(async move { + assert_eq!(request.uri(), "/rewritten?encoded=%2F"); + assert_eq!(request.method(), "PATCH"); + assert_eq!(request.headers().get_all("x-duplicate").iter().count(), 2); + assert_eq!(request.extensions().get::(), Some(&123)); + assert_eq!( + request + .extensions() + .get::() + .unwrap() + .timeout, + None + ); + let mut response = core::Response::new(request.into_body()); + response.extensions_mut().insert(456_u32); + Ok(response) + }) + }); + let response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "http-request".into(), + cancellation: CancellationToken::new(), + }, + request, + next, + ) + .await + .unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 0); + assert_eq!(response.headers()["x-http-plugin"], "active"); + assert_eq!(response.extensions().get::(), Some(&456)); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "opaque request" + ); + drop(plan); + drop(generation); + runtime.shutdown().await; + crate::generation::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn real_http_plugin_maps_stream_and_preserves_trailers() { + let (_cache, runtime, generation, plan) = http_plugin("map").await; + let request = ::http::Request::builder() + .uri("/any") + .header("authorization", "Bearer fixture-secret") + .body(core::empty_body()) + .unwrap(); + let next = compose(Vec::new(), |_: core::Request| { + Box::pin(async { + let mut trailers = ::http::HeaderMap::new(); + trailers.append("x-end", "one".parse().unwrap()); + trailers.append("x-end", "two".parse().unwrap()); + let body = StreamBody::new(futures::stream::iter(vec![ + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"first"))), + Ok(Frame::data(Bytes::from_static(b"second"))), + Ok(Frame::trailers(trailers)), + ])) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + Ok(core::Response::new(body)) + }) + }); + let response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "http-stream".into(), + cancellation: CancellationToken::new(), + }, + request, + next, + ) + .await + .unwrap(); + let body = response.into_body().collect().await.unwrap(); + assert_eq!(body.trailers().unwrap().get_all("x-end").iter().count(), 2); + assert_eq!(body.to_bytes(), "FIRSTSECOND"); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn real_http_plugin_can_respond_to_unknown_route_without_calling_terminal() { + let (_cache, runtime, generation, plan) = http_plugin("short").await; + let request = ::http::Request::builder() + .uri("/plugin-owned") + .body(core::empty_body()) + .unwrap(); + let next = compose(Vec::new(), |_: core::Request| { + Box::pin(async { panic!("short circuit cannot route") }) + }); + let response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "http-short".into(), + cancellation: CancellationToken::new(), + }, + request, + next, + ) + .await + .unwrap(); + assert_eq!(response.status(), 418); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "plugin route" + ); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn transformed_http_body_respects_a_smaller_host_credit_window() { + let (_cache, runtime, generation, plan) = http_plugin_with_limits( + "map", + RpcLimits { + maximum_buffered_body_bytes: 8192, + ..Default::default() + }, + ) + .await; + let request = ::http::Request::builder() + .uri("/large-frame") + .header("authorization", "Bearer fixture-secret") + .body(core::empty_body()) + .unwrap(); + let next = compose(Vec::new(), |_: core::Request| { + Box::pin(async { + // 单个源帧大于窗口,HTTP 转换应按可用信用拆分,而不要求业务更改源帧大小。 + Ok(core::Response::new( + http_body_util::Full::new(Bytes::from(vec![b'a'; 65536])) + .map_err(|never| match never {}) + .boxed_unsync(), + )) + }) + }); + let response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "small-window".into(), + cancellation: CancellationToken::new(), + }, + request, + next, + ) + .await + .unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from(vec![b'A'; 65536])); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn real_http_plugin_transforms_upload_with_early_and_late_response_headers() { + for early_headers in [false, true] { + let (_cache, runtime, generation, plan) = http_plugin("upload").await; + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let frames = (0..96).map(move |_| { + observed.fetch_add(1, Ordering::SeqCst); + Ok::<_, std::io::Error>(Frame::data(Bytes::from(vec![b'a'; 65536]))) + }); + let body = StreamBody::new(futures::stream::iter(frames)) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let request = ::http::Request::builder() + .uri("/upload") + .header("authorization", "Bearer fixture-secret") + .body(body) + .unwrap(); + let next = compose(Vec::new(), move |request: core::Request| { + Box::pin(async move { + if early_headers { + Ok(core::Response::new(request.into_body())) + } else { + let bytes = request.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(bytes.len(), 96 * 65536); + assert!(bytes.iter().all(|byte| *byte == b'A')); + Ok(core::Response::new( + http_body_util::Full::new(bytes) + .map_err(|never| match never {}) + .boxed_unsync(), + )) + } + }) + }); + let response = tokio::time::timeout( + Duration::from_secs(5), + plan.handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "upload".into(), + cancellation: CancellationToken::new(), + }, + request, + next, + ), + ) + .await + .unwrap() + .unwrap_or_else(|error| { + if let MiddlewareError::Remote { source, .. } = &error + && let Some(gateway_plugin_runtime::RpcError::Remote(fault)) = + source.downcast_ref::() + { + panic!("upload middleware: {}", fault.message); + } + panic!("upload middleware: {error:?}"); + }); + if early_headers { + assert!(reads.load(Ordering::SeqCst) < 96, "响应头不能等待完整上传"); + } + let body = tokio::time::timeout(Duration::from_secs(5), response.into_body().collect()) + .await + .unwrap() + .unwrap_or_else(|error| { + if let Some(MiddlewareError::Remote { source, .. }) = + error.downcast_ref::() + && let Some(gateway_plugin_runtime::RpcError::Remote(fault)) = + source.downcast_ref::() + { + panic!("upload response (early={early_headers}): {}", fault.message); + } + panic!("upload response (early={early_headers}): {error:?}"); + }) + .to_bytes(); + assert_eq!(body.len(), 96 * 65536); + assert!(body.iter().all(|byte| *byte == b'A')); + drop(plan); + drop(generation); + runtime.shutdown().await; + } +} + +#[tokio::test] +async fn early_rejection_does_not_wait_for_or_mask_an_unconsumed_upload() { + let (_cache, runtime, generation, plan) = http_plugin("upload").await; + let body = StreamBody::new(futures::stream::repeat_with(|| { + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"ignored"))) + })) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let request = ::http::Request::builder() + .uri("/upload") + .header("authorization", "Bearer fixture-secret") + .body(body) + .unwrap(); + let next = compose(Vec::new(), move |_: core::Request| { + Box::pin(async { + let mut response = core::Response::new(core::empty_body()); + *response.status_mut() = ::http::StatusCode::UNAUTHORIZED; + Ok(response) + }) + }); + let response = tokio::time::timeout( + Duration::from_secs(5), + plan.handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "upload-rejected".into(), + cancellation: CancellationToken::new(), + }, + request, + next, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), 401); + assert!( + tokio::time::timeout(Duration::from_secs(5), response.into_body().collect()) + .await + .unwrap() + .unwrap() + .to_bytes() + .is_empty() + ); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +struct UploadLifetime(Arc); +impl futures::Stream for UploadLifetime { + type Item = Result, std::io::Error>; + fn poll_next( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Some(Ok(Frame::data(Bytes::from_static( + b"still uploading", + ))))) + } +} +impl Drop for UploadLifetime { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +#[tokio::test] +async fn dropping_or_cancelling_a_response_releases_its_active_upload() { + for cancel in [false, true] { + let (_cache, runtime, generation, plan) = http_plugin("upload").await; + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let body = StreamBody::new(UploadLifetime(dropped.clone())) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(); + let request = ::http::Request::builder() + .uri("/upload") + .header("authorization", "Bearer fixture-secret") + .body(body) + .unwrap(); + let cancellation = CancellationToken::new(); + let response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + plan: None, + request_id: "cancel-upload".into(), + cancellation: cancellation.clone(), + }, + request, + compose(Vec::new(), |request: core::Request| { + Box::pin(async { Ok(core::Response::new(request.into_body())) }) + }), + ) + .await + .unwrap(); + let mut body = response.into_body(); + assert!(body.frame().await.unwrap().is_ok()); + assert!(!dropped.load(Ordering::SeqCst)); + if cancel { + cancellation.cancel(); + assert!(body.frame().await.unwrap().is_err()); + } + drop(body); + tokio::time::timeout(Duration::from_secs(5), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("upload owner must drop with its response"); + drop(plan); + drop(generation); + runtime.shutdown().await; + } +} + +/// 显式以 release 运行,避免把调试构建或进程启动时间计入请求开销。 +#[cfg(not(debug_assertions))] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "run with --release --ignored --nocapture for the HTTP middleware baseline"] +async fn http_onion_performance_baseline() { + let worker = std::fs::read( + std::env::var_os("CPR_PLUGIN_BENCH_WORKER") + .unwrap_or_else(|| env!("CARGO_BIN_EXE_gateway-plugin-test-middleware").into()), + ) + .unwrap(); + let selected = std::env::var("CPR_PLUGIN_BENCH_CASE").ok(); + assert!(selected.as_deref().is_none_or(|case| { + ["native", "passthrough", "compose-three", "transform"].contains(&case) + })); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Http], + vec!["http".into()], + vec!["http".into()], + )]), + ); + for (label, mode, count) in [ + ("native", "identity", 0), + ("passthrough", "identity", 1), + ("compose-three", "identity", 3), + ("transform", "map", 1), + ] { + if selected.as_deref().is_some_and(|case| case != label) { + continue; + } + let instances = ["bench-a", "bench-b", "bench-c"] + .into_iter() + .take(count) + .enumerate() + .map(|(index, id)| InstanceFixture { + id, + configuration: serde_json::json!({"http":true,"mode":mode}), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "http", + index as i32, + PluginFailurePolicy::Reject, + )], + }) + .collect(); + let (_cache, runtime) = setup_package(instances, package.clone()).await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation); + let snapshot = gateway_core::routing::RuntimeSnapshot::new( + ConfigRevision::new(1).unwrap(), + gateway_core::settings::SettingsValues::new( + 1, + 0, + "smart", + Default::default(), + None, + None, + ), + vec![], + vec![], + vec![], + ) + .unwrap(); + let settings = gateway_core::settings::RequestSettings::new(Arc::new(snapshot)); + for size in [64 * 1024, 2 * 1024 * 1024] { + let payload = Bytes::from(vec![b'a'; size]); + for _ in 0..5 { + benchmark_http_request(plan.as_ref(), &settings, payload.clone(), mode == "map") + .await; + } + let mut first_frames = Vec::new(); + let mut totals = Vec::new(); + for _ in 0..50 { + let (first, total) = benchmark_http_request( + plan.as_ref(), + &settings, + payload.clone(), + mode == "map", + ) + .await; + first_frames.push(first.as_secs_f64() * 1000.0); + totals.push(total.as_secs_f64() * 1000.0); + } + first_frames.sort_by(f64::total_cmp); + totals.sort_by(f64::total_cmp); + 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") + })) + .await; + } + println!( + "{}", + serde_json::json!({ + "case":label,"bytes":size,"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(), + }) + ); + } + drop(plan); + drop(generation); + runtime.shutdown().await; + } +} + +#[cfg(not(debug_assertions))] +async fn benchmark_http_request( + plan: Option<&gateway_core::engine::middleware::FrozenMiddlewarePlan>, + settings: &gateway_core::settings::RequestSettings, + payload: Bytes, + uppercase: bool, +) -> (Duration, Duration) { + let length = payload.len(); + let frames = (0..length).step_by(64 * 1024).map(move |offset| { + Ok::<_, std::io::Error>(Frame::data( + payload.slice(offset..(offset + 64 * 1024).min(length)), + )) + }); + let mut request = ::http::Request::builder() + .uri("/benchmark") + .header("authorization", "Bearer fixture-secret") + .body( + StreamBody::new(futures::stream::iter(frames)) + .map_err(|error| Box::new(error) as _) + .boxed_unsync(), + ) + .unwrap(); + request.extensions_mut().insert(core::Settings { + runtime: Some(settings.clone()), + timeout: None, + }); + let next = compose(Vec::new(), |request: core::Request| { + Box::pin(async { Ok(core::Response::new(request.into_body())) }) + }); + let started = std::time::Instant::now(); + let response = match plan { + Some(plan) => { + plan.handle_http( + core::Context { + plugin_instance_id: None, + request_id: "benchmark".into(), + call_id: "benchmark".into(), + parent_call_id: None, + extensions: Default::default(), + cancellation: CancellationToken::new(), + plan: Some(plan.clone()), + }, + request, + next, + ) + .await + } + None => next.run(request).await, + } + .unwrap(); + let mut body = response.into_body(); + let mut first = None; + let mut bytes = 0; + while let Some(frame) = body.frame().await { + let frame = frame.unwrap(); + if let Some(data) = frame.data_ref() { + first.get_or_insert_with(|| started.elapsed()); + assert!( + data.iter() + .all(|byte| *byte == if uppercase { b'A' } else { b'a' }) + ); + bytes += data.len(); + } + } + assert_eq!(bytes, length); + (first.unwrap(), started.elapsed()) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/websocket/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/websocket/mod.rs new file mode 100644 index 000000000..58fc07fd2 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/websocket/mod.rs @@ -0,0 +1,347 @@ +use super::*; +use futures::FutureExt as _; +use gateway_core::middleware::{http::upgrade, websocket as ws}; +use std::sync::atomic::AtomicBool; + +struct Transport { + incoming: tokio::sync::Mutex>, + outgoing: tokio::sync::mpsc::Sender, + dropped: Arc, +} +impl Drop for Transport { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} +impl ws::Sender for Transport { + fn send(&self, message: ws::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async { + self.outgoing + .send(message) + .await + .map_err(|_| MiddlewareError::Fault) + }) + } +} +impl ws::Session for Transport { + fn receive(&self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async { Ok(self.incoming.lock().await.recv().await) }) + } +} +struct Upgrade(Arc); +impl upgrade::WebSocketUpgrade for Upgrade { + fn accept( + &self, + parts: ::http::request::Parts, + protocols: Vec, + ) -> BoxFuture<'static, Result<(core::Response, upgrade::PendingSession), MiddlewareError>> + { + assert_eq!(parts.uri, "/custom/session"); + assert_eq!(protocols, ["test-session"]); + let session = self.0.clone(); + Box::pin(async move { + let mut response = core::Response::new(core::empty_body()); + *response.status_mut() = ::http::StatusCode::SWITCHING_PROTOCOLS; + Ok(( + response, + async move { Ok(session as Arc) } + .boxed() + .shared(), + )) + }) + } +} + +#[tokio::test] +async fn session_model_and_network_callbacks_outlive_handshake_and_each_execution_has_its_own_budget() + { + let Some(mut environment) = Environment::create_command().await else { + return; + }; + let account = environment.account(None).await; + let key = format!("key_{}", uuid::Uuid::new_v4().simple()); + environment.client_key(&key, "sk-session-fixture").await; + environment + .install_plugin(serde_json::json!({"http":true,"mode":"callbacks"})) + .await; + let (runtime, bundle) = environment + .runtime_with_limits(RpcLimits { + maximum_call_timeout: Duration::from_millis(500), + ..RpcLimits::default() + }) + .await; + environment.store.start_command_line_writes().unwrap(); + let snapshot = bundle.snapshots().snapshot_for_diagnostics().unwrap(); + let plan = runtime + .middleware_registry() + .resolve(snapshot.extensions().unwrap()) + .unwrap(); + let (send, incoming) = tokio::sync::mpsc::channel(1); + let (outgoing, mut receive) = tokio::sync::mpsc::channel(1); + let dropped = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(Transport { + incoming: tokio::sync::Mutex::new(incoming), + outgoing, + dropped: dropped.clone(), + }); + let mut request = ::http::Request::builder() + .uri("/custom/session") + .body(core::empty_body()) + .unwrap(); + request + .extensions_mut() + .insert(Arc::new(Upgrade(transport)) as Arc); + let cancellation = CancellationToken::new(); + let mut response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + request_id: "session-models".into(), + plan: Some(plan.clone()), + cancellation: cancellation.clone(), + }, + request, + compose(Vec::new(), |_| { + Box::pin(async { panic!("custom session owns transport") }) + }), + ) + .await + .unwrap(); + let task = tokio::spawn( + response + .extensions_mut() + .remove::() + .unwrap() + .take_task() + .unwrap(), + ); + drop(response); + assert_eq!(receive.recv().await.unwrap().payload, "ready"); + tokio::time::sleep(Duration::from_millis(700)).await; + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_string("network reply")) + .expect(1) + .mount(&server) + .await; + let call = |command| ws::Message { + kind: ws::Kind::Text, + payload: Bytes::from(serde_json::to_vec(&command).unwrap()), + }; + send.send(call(serde_json::json!({"method":"host.http.do","params":{"method":"GET","url":server.uri(),"headers":[]}}))).await.unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(5), receive.recv()) + .await + .unwrap() + .unwrap(); + let reply: serde_json::Value = serde_json::from_slice(&reply.payload).unwrap(); + assert_eq!(reply["result"]["status"], 200); + assert_eq!(reply["payload_bytes"], 13); + // 超过单个子调用图的 16 次预算,连接仍可承载新的独立执行。 + let mut requests = BTreeSet::new(); + for _ in 0..18 { + send.send(call(serde_json::json!({"method":"host.model.execute","params":{ + "client_key_id":key,"model":crate::support::native::MODEL,"protocol":"openai","operation":"generate","provider":"openai","account_id":account.as_str() + },"body":{"model":crate::support::native::MODEL,"input":"session request"}}))).await.unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(5), receive.recv()) + .await + .unwrap() + .expect("model callback reply"); + let reply: serde_json::Value = serde_json::from_slice(&reply.payload).unwrap(); + assert!(reply["result"]["events"].as_u64().unwrap() > 0); + assert!(requests.insert(reply["result"]["request_id"].as_str().unwrap().to_owned())); + } + assert_eq!( + environment + .wait_for_bound_model_requests(&key, 18) + .await + .len(), + 18 + ); + cancellation.cancel(); + assert!( + tokio::time::timeout(Duration::from_secs(5), task) + .await + .unwrap() + .unwrap() + .is_err() + ); + assert!(dropped.load(Ordering::SeqCst)); + drop(plan); + drop(snapshot); + drop(bundle); + runtime.shutdown().await; + environment + .store + .shutdown_command_line_writes() + .await + .unwrap(); + environment.close().await; +} + +#[tokio::test] +async fn real_plugin_controls_upgrade_and_concurrent_duplex_with_large_binary_frames() { + let (_cache, runtime, generation, plan) = http_plugin_with_limits( + "upgrade", + RpcLimits { + maximum_call_timeout: Duration::from_millis(500), + ..RpcLimits::default() + }, + ) + .await; + let (send, incoming) = tokio::sync::mpsc::channel(1); + let (outgoing, mut receive) = tokio::sync::mpsc::channel(1); + let dropped = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(Transport { + incoming: tokio::sync::Mutex::new(incoming), + outgoing, + dropped: dropped.clone(), + }); + let mut request = ::http::Request::builder() + .uri("/custom/session") + .body(core::empty_body()) + .unwrap(); + request + .extensions_mut() + .insert(Arc::new(Upgrade(transport)) as Arc); + let cancellation = CancellationToken::new(); + let mut response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + request_id: "session-test".into(), + plan: Some(plan.clone()), + cancellation, + }, + request, + compose(Vec::new(), |_| { + Box::pin(async { panic!("custom session must not call the native router") }) + }), + ) + .await + .unwrap(); + assert_eq!(response.status(), 101); + let upgraded = response + .extensions_mut() + .remove::() + .unwrap(); + let task = tokio::spawn(upgraded.take_task().unwrap()); + drop(response); + tokio::time::timeout(Duration::from_secs(5), async { + assert_eq!(receive.recv().await.unwrap().payload, "ready"); + tokio::time::sleep(Duration::from_millis(700)).await; + assert!( + !dropped.load(Ordering::SeqCst), + "会话不能在初始调用期限结束时关闭" + ); + let payload = Bytes::from(vec![0x81; 20 * 1024 * 1024]); + send.send(ws::Message { + kind: ws::Kind::Binary, + payload: payload.clone(), + }) + .await + .unwrap(); + let echoed = receive.recv().await.unwrap(); + assert_eq!(echoed.kind, ws::Kind::Binary); + assert_eq!(echoed.payload, payload); + for index in 0..32 { + let payload = Bytes::from(format!("message {index}")); + send.send(ws::Message { + kind: ws::Kind::Text, + payload: payload.clone(), + }) + .await + .unwrap(); + assert_eq!(receive.recv().await.unwrap().payload, payload); + } + let close = receive.recv().await.unwrap(); + assert_eq!(close.kind, ws::Kind::Close { code: Some(1000) }); + assert_eq!(close.payload, "done"); + task.await.unwrap().unwrap(); + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(plan); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn dropping_an_uncommitted_upgrade_or_cancelling_a_session_reclaims_its_socket() { + for active in [false, true] { + let (_cache, runtime, generation, plan) = http_plugin("upgrade").await; + let (_send, incoming) = tokio::sync::mpsc::channel(1); + let (outgoing, mut receive) = tokio::sync::mpsc::channel(1); + let dropped = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(Transport { + incoming: tokio::sync::Mutex::new(incoming), + outgoing, + dropped: dropped.clone(), + }); + let mut request = ::http::Request::builder() + .uri("/custom/session") + .body(core::empty_body()) + .unwrap(); + request + .extensions_mut() + .insert(Arc::new(Upgrade(transport)) as Arc); + let cancellation = CancellationToken::new(); + let mut response = plan + .handle_http( + core::Context { + plugin_instance_id: None, + call_id: "http-call".into(), + parent_call_id: None, + extensions: Default::default(), + request_id: "cancel-session".into(), + plan: None, + cancellation: cancellation.clone(), + }, + request, + compose(Vec::new(), |_| { + Box::pin(async { panic!("no native route") }) + }), + ) + .await + .unwrap(); + if active { + let task = tokio::spawn( + response + .extensions_mut() + .remove::() + .unwrap() + .take_task() + .unwrap(), + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), receive.recv()) + .await + .unwrap() + .unwrap() + .payload, + "ready" + ); + cancellation.cancel(); + assert!(task.await.unwrap().is_err()); + } + drop(response); + tokio::time::timeout(Duration::from_secs(5), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(plan); + drop(generation); + runtime.shutdown().await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs similarity index 63% rename from backend/crates/gateway-plugin/runtime/tests/generation/policy.rs rename to backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs index f5b8d12af..835345dbc 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs @@ -1,3 +1,9 @@ +mod facts; +mod http; +mod mounts; +mod service; +mod websocket; + use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, num::{NonZeroU32, NonZeroUsize}, @@ -15,7 +21,6 @@ use gateway_admin::{ Revision, plugins::instances::{ PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot, - PluginPermissionGrant, }, }, ports::plugins::PluginPackageInspector, @@ -48,18 +53,15 @@ use gateway_core::{ use gateway_plugin_runtime::{ PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, }; -use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; -use crate::support::{ - environment::{Environment, account_grant}, - store::Store, -}; +use crate::support::{environment::Environment, store::Store}; struct InstanceFixture { id: &'static str, configuration: serde_json::Value, - grants: Vec, + bindings: Vec, } @@ -82,20 +84,12 @@ fn binding( account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![], } } -fn grant(permission: Permission) -> PluginPermissionGrant { - PluginPermissionGrant { - permission: permission.as_str().to_owned(), - } -} - -async fn setup( - instances: Vec, - manifest_permissions: Vec, -) -> (tempfile::TempDir, PluginRuntime) { +async fn setup(instances: Vec) -> (tempfile::TempDir, PluginRuntime) { let contributes = Contributions::from([ crate::support::contribution(Capability::RetryPolicy, vec![Stage::Retry], vec![], vec![]), crate::support::contribution( @@ -119,11 +113,7 @@ async fn setup( ]); setup_package( instances, - crate::support::package_with_contributions( - crate::support::worker(), - manifest_permissions, - contributes, - ), + crate::support::package_with_contributions(crate::support::worker(), contributes), ) .await } @@ -131,6 +121,14 @@ async fn setup( async fn setup_package( instances: Vec, package: Arc<[u8]>, +) -> (tempfile::TempDir, PluginRuntime) { + setup_package_with_limits(instances, package, RpcLimits::default()).await +} + +async fn setup_package_with_limits( + instances: Vec, + package: Arc<[u8]>, + rpc_limits: RpcLimits, ) -> (tempfile::TempDir, PluginRuntime) { let cache = tempfile::tempdir().unwrap(); let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) @@ -148,7 +146,7 @@ async fn setup_package( trusted_process: true, configuration: fixture.configuration, secrets: BTreeMap::new(), - grants: fixture.grants, + bindings: fixture.bindings, revision: Revision::new(1).unwrap(), }) @@ -167,17 +165,13 @@ async fn setup_package( cache_directory: cache.path().to_owned(), host_version: "1.0.0".parse().unwrap(), package_limits: PackageLimits::default(), - rpc_limits: RpcLimits::default(), + rpc_limits, restart_circuit: Default::default(), }, Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), Arc::new(gateway_host::process::ProcessSupervisor::new( NonZeroUsize::new(32).unwrap(), )), - ) - .with_network_policy( - gateway_host::outbound::NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]) - .unwrap(), ); (cache, runtime) } @@ -260,11 +254,28 @@ async fn retry_policy_uses_ordered_delegation_and_rejects_disallowed_actions() { let records = tempfile::tempdir().unwrap(); let marker = records.path().join("retry.jsonl"); let (_cache, runtime) = setup(vec![ - InstanceFixture { id: "retry-first", configuration: serde_json::json!({"retry_decision":reply,"retry_marker":marker}), grants: vec![], - bindings: vec![binding("test.example.retryPolicy", "retry", 0, PluginFailurePolicy::Delegate)] }, - InstanceFixture { id: "retry-second", configuration: serde_json::json!({"retry_decision":{"decision":"stop"}}), grants: vec![], - bindings: vec![binding("test.example.retryPolicy", "retry", 1, PluginFailurePolicy::Delegate)] }, - ], vec![]).await; + InstanceFixture { + id: "retry-first", + configuration: serde_json::json!({"retry_decision":reply,"retry_marker":marker}), + bindings: vec![binding( + "test.example.retryPolicy", + "retry", + 0, + PluginFailurePolicy::Delegate, + )], + }, + InstanceFixture { + id: "retry-second", + configuration: serde_json::json!({"retry_decision":{"decision":"stop"}}), + bindings: vec![binding( + "test.example.retryPolicy", + "retry", + 1, + PluginFailurePolicy::Delegate, + )], + }, + ]) + .await; let context = policy_context(&runtime, prepare(&runtime).await, "req_retry"); let decision = context .retry_decision(RetryFacts { @@ -309,17 +320,17 @@ async fn retry_policy_timeout_exhausts_the_chain_budget_and_delegates() { InstanceFixture { id: "retry-delayed", configuration: serde_json::json!({"retry_delay_ms":500,"retry_decision":{"decision":"stop"}}), - grants: vec![], + bindings: vec![binding("test.example.retryPolicy", "retry", 0, PluginFailurePolicy::Delegate)], }, InstanceFixture { id: "retry-next", configuration: serde_json::json!({"retry_marker":second_marker,"retry_decision":{"decision":"stop"}}), - grants: vec![], + bindings: vec![binding("test.example.retryPolicy", "retry", 1, PluginFailurePolicy::Delegate)], }, ], - vec![], + ).await; let context = policy_context(&runtime, prepare(&runtime).await, "req_retry_timeout"); let decision = tokio::time::timeout( @@ -365,42 +376,39 @@ async fn marker_lines(path: &std::path::Path, count: usize) -> Vec(lines[1]["body"].as_str().unwrap()).unwrap(), serde_json::json!({"model":"public-a","input":"synthetic"}) @@ -433,6 +444,9 @@ async fn model_router_preserves_order_and_only_projects_authorized_request_data( lines[1]["request"]["headers"], serde_json::json!([ {"name":"x-feature","value_base64":"b24="}, + {"name":"authorization","value_base64":"c2VjcmV0"}, + {"name":"session_id","value_base64":"c2VjcmV0"}, + {"name":"thread_id","value_base64":"c2VjcmV0"}, {"name":"x_extension!#$%&'*+-.^_`|~","value_base64":"b24="} ]) ); @@ -450,38 +464,35 @@ async fn model_router_can_delegate_after_a_managed_http_side_effect() { .expect(1) .mount(&server) .await; - let (cache, runtime) = setup( - vec![ - InstanceFixture { - id: "http-then-fault", - configuration: serde_json::json!({ - "route_http_url":server.uri(), - "route_fault":true, - }), - grants: vec![grant(Permission::Network)], - bindings: vec![binding( - MODEL_ROUTER_CONTRIBUTION, - "routing", - 1, - PluginFailurePolicy::Delegate, - )], - }, - InstanceFixture { - id: "fallback", - configuration: serde_json::json!({ - "route_decision":{"decision":"unhandled"}, - }), - grants: vec![], - bindings: vec![binding( - MODEL_ROUTER_CONTRIBUTION, - "routing", - 2, - PluginFailurePolicy::Reject, - )], - }, - ], - vec![Permission::Network], - ) + let (cache, runtime) = setup(vec![ + InstanceFixture { + id: "http-then-fault", + configuration: serde_json::json!({ + "route_http_url":server.uri(), + "route_fault":true, + }), + + bindings: vec![binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 1, + PluginFailurePolicy::Delegate, + )], + }, + InstanceFixture { + id: "fallback", + configuration: serde_json::json!({ + "route_decision":{"decision":"unhandled"}, + }), + + bindings: vec![binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 2, + PluginFailurePolicy::Reject, + )], + }, + ]) .await; let generation = prepare(&runtime).await; let context = policy_context(&runtime, generation.clone(), "req_route_http"); @@ -551,20 +562,17 @@ fn selection_context() -> AccountSelectionContext { #[tokio::test] async fn scheduler_cannot_pick_outside_the_host_projected_priority_tier() { - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "scheduler", - configuration: serde_json::json!({"schedule_pick_index":0}), - grants: vec![], - bindings: vec![binding( - SCHEDULER_CONTRIBUTION, - "scheduling", - 0, - PluginFailurePolicy::Reject, - )], - }], - vec![], - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id: "scheduler", + configuration: serde_json::json!({"schedule_pick_index":0}), + + bindings: vec![binding( + SCHEDULER_CONTRIBUTION, + "scheduling", + 0, + PluginFailurePolicy::Reject, + )], + }]) .await; let generation = prepare(&runtime).await; let context = policy_context(&runtime, generation.clone(), "req_schedule"); @@ -615,50 +623,53 @@ struct Downstream { error: bool, } -impl MiddlewareNext for Downstream { - fn run( - self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - self.calls.fetch_add(1, Ordering::Relaxed); - assert_eq!(request.protocol(), "openai"); - assert_eq!(request.body(), &Bytes::from_static(br#"{"input":"hello"}"#)); - assert!(request.headers().iter().any(|header| { - header.name() == "authorization" && header.value().as_ref() == b"Bearer private" - })); - assert!( - request - .headers() - .iter() - .any(|header| header.name() == "x_team" && header.value().as_ref() == b"team") - ); - let (status, framing, body) = if self.error { - (502, MiddlewareFraming::RawBytes, b"\0upstream".as_slice()) - } else { - ( - 200, - MiddlewareFraming::JsonDocument, - br#"{"downstream":true}"#.as_slice(), - ) - }; - Ok(MiddlewareResponse::new( - "openai".into(), - status, - vec![ - MiddlewareHeader::new("content-type", Bytes::from_static(b"application/json")), - MiddlewareHeader::new("x-upstream-marker", Bytes::from_static(b"kept")), - ], - Box::new(OneFrameBody { - reads: Arc::clone(&self.reads), - frame: Some(MiddlewareFrame::new( - Bytes::copy_from_slice(body), - framing, - true, - )), - closes: Arc::clone(&self.closes), - }), - )) +impl Downstream { + fn into_next(self) -> MiddlewareNext { + gateway_core::middleware::compose(Vec::new(), move |request| { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::Relaxed); + assert_eq!(request.protocol(), "openai"); + assert_eq!(request.body(), &Bytes::from_static(br#"{"input":"hello"}"#)); + assert!(request.headers().iter().any(|header| { + header.name() == "authorization" && header.value().as_ref() == b"Bearer private" + })); + assert!( + request + .headers() + .iter() + .any(|header| header.name() == "x_team" + && header.value().as_ref() == b"team") + ); + let (status, framing, body) = if self.error { + (502, MiddlewareFraming::RawBytes, b"\0upstream".as_slice()) + } else { + ( + 200, + MiddlewareFraming::JsonDocument, + br#"{"downstream":true}"#.as_slice(), + ) + }; + Ok(MiddlewareResponse::new( + "openai".into(), + status, + vec![ + MiddlewareHeader::new( + "content-type", + Bytes::from_static(b"application/json"), + ), + MiddlewareHeader::new("x-upstream-marker", Bytes::from_static(b"kept")), + ], + Box::new(OneFrameBody { + reads: Arc::clone(&self.reads), + frame: Some(MiddlewareFrame::new( + Bytes::copy_from_slice(body), + framing, + true, + )), + closes: Arc::clone(&self.closes), + }), + )) + }) }) } } @@ -733,23 +744,20 @@ async fn attempt_middleware_can_delegate_after_a_managed_http_side_effect() { .expect(1) .mount(&server) .await; - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "attempt-http", - configuration: serde_json::json!({ - "middleware_http_url":server.uri(), - "middleware_fault_before_next":true, - }), - grants: vec![grant(Permission::Network)], - bindings: vec![binding( - MIDDLEWARE_CONTRIBUTION, - "attempt", - 0, - PluginFailurePolicy::Delegate, - )], - }], - vec![Permission::Network], - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id: "attempt-http", + configuration: serde_json::json!({ + "middleware_http_url":server.uri(), + "middleware_fault_before_next":true, + }), + + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "attempt", + 0, + PluginFailurePolicy::Delegate, + )], + }]) .await; let generation = prepare(&runtime).await; let plan = runtime @@ -767,12 +775,13 @@ async fn attempt_middleware_can_delegate_after_a_managed_http_side_effect() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), closes: Arc::clone(&closes), reads: Arc::default(), error: false, - }), + }) + .into_next(), ) .await .unwrap(); @@ -789,24 +798,21 @@ async fn attempt_middleware_can_delegate_after_a_managed_http_side_effect() { async fn middleware_next_and_lazy_body_mapping_use_one_real_rpc_call() { let markers = tempfile::tempdir().unwrap(); let marker = markers.path().join("middleware.jsonl"); - let permissions = vec![Permission::Requests]; - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "middleware", - configuration: serde_json::json!({ - "middleware_marker":marker, - "middleware_map_body":true, - }), - grants: permissions.iter().copied().map(grant).collect(), - bindings: vec![binding( - MIDDLEWARE_CONTRIBUTION, - "request", - 0, - PluginFailurePolicy::Reject, - )], - }], - permissions, - ) + + let (cache, runtime) = setup(vec![InstanceFixture { + id: "middleware", + configuration: serde_json::json!({ + "middleware_marker":marker, + "middleware_map_body":true, + }), + + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "request", + 0, + PluginFailurePolicy::Reject, + )], + }]) .await; let generation = prepare(&runtime).await; let plan = runtime @@ -824,12 +830,13 @@ async fn middleware_next_and_lazy_body_mapping_use_one_real_rpc_call() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), closes: Arc::clone(&closes), reads: Arc::default(), error: false, - }), + }) + .into_next(), ) .await .unwrap(); @@ -854,10 +861,14 @@ async fn middleware_next_and_lazy_body_mapping_use_one_real_rpc_call() { assert_eq!(first_marker["body"], "{\"input\":\"hello\"}"); assert_eq!( first_marker["headers"], - serde_json::json!([{ - "name":"x_team", - "value":[116,101,97,109], - }]) + serde_json::Value::Array( + middleware_headers() + .iter() + .map(|header| serde_json::json!({ + "name":header.name(), "value":header.value().to_vec(), + })) + .collect() + ) ); body.close().await; tokio::time::timeout(Duration::from_secs(1), async { @@ -876,12 +887,13 @@ async fn middleware_next_and_lazy_body_mapping_use_one_real_rpc_call() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), closes: Arc::clone(&closes), reads: Arc::default(), error: false, - }), + }) + .into_next(), ) .await .unwrap(); @@ -904,12 +916,13 @@ async fn middleware_next_and_lazy_body_mapping_use_one_real_rpc_call() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), closes: Arc::clone(&closes), reads: Arc::default(), error: true, - }), + }) + .into_next(), ) .await .unwrap(); @@ -939,25 +952,21 @@ async fn middleware_classifies_real_rpc_output_after_a_terminal_frame() { ("middleware-terminal-chunk", true, false), ("middleware-terminal-error", false, true), ] { - let permissions = vec![Permission::Requests]; - let (cache, runtime) = setup( - vec![InstanceFixture { - id, - configuration: serde_json::json!({ - "middleware_map_body": true, - "middleware_chunk_after_terminal": extra_chunk, - "middleware_error_after_terminal": terminal_error, - }), - grants: permissions.iter().copied().map(grant).collect(), - bindings: vec![binding( - MIDDLEWARE_CONTRIBUTION, - "request", - 0, - PluginFailurePolicy::Reject, - )], - }], - permissions, - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id, + configuration: serde_json::json!({ + "middleware_map_body": true, + "middleware_chunk_after_terminal": extra_chunk, + "middleware_error_after_terminal": terminal_error, + }), + + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "request", + 0, + PluginFailurePolicy::Reject, + )], + }]) .await; let generation = prepare(&runtime).await; let plan = runtime.middleware_registry().resolve(&generation).unwrap(); @@ -971,12 +980,13 @@ async fn middleware_classifies_real_rpc_output_after_a_terminal_frame() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), closes: Arc::clone(&closes), reads: Arc::default(), error: false, - }), + }) + .into_next(), ) .await .unwrap(); @@ -986,7 +996,7 @@ async fn middleware_classifies_real_rpc_output_after_a_terminal_frame() { .await .expect_err("output after the terminal frame must fail"); let expected = if terminal_error { - matches!(&error, MiddlewareError::Fault) + matches!(&error, MiddlewareError::Remote { .. }) } else { matches!(&error, MiddlewareError::InvalidState) }; @@ -1003,12 +1013,274 @@ async fn middleware_classifies_real_rpc_output_after_a_terminal_frame() { } #[tokio::test] -async fn sdk_middleware_entry_registers_and_maps_a_real_process_response() { +async fn outer_plugin_receives_inner_rejection_details_and_preserves_rejection_status() { let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); - let permissions = vec![Permission::Requests]; let package = crate::support::package_with_contributions( &worker, - permissions.clone(), + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Request], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let details = serde_json::json!({"reason":"fixture","disabled":false}); + let (cache,runtime) = setup_package(vec![ + InstanceFixture {id:"outer-error", configuration:serde_json::json!({"mode":"downstream_error","remote_error":true,"expected_message":"plugin rejection details","expected_details":details}), bindings:vec![binding(MIDDLEWARE_CONTRIBUTION,"request",0,PluginFailurePolicy::Reject)]}, + InstanceFixture {id:"inner-error", configuration:serde_json::json!({"mode":"rejected"}), bindings:vec![binding(MIDDLEWARE_CONTRIBUTION,"request",1,PluginFailurePolicy::Reject)]}, + ],package).await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let result = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new("openai", middleware_headers(), Bytes::new()), + gateway_core::middleware::compose(Vec::new(), |_| { + Box::pin(async { panic!("rejection must not invoke terminal") }) + }), + ) + .await; + let Err(error) = result else { + panic!("expected rejection") + }; + assert!(error.is_rejected()); + let MiddlewareError::Remote { source, .. } = error else { + panic!("expected remote details") + }; + let Some(gateway_plugin_runtime::RpcError::Remote(fault)) = + source.downcast_ref::() + else { + panic!("expected plugin error") + }; + assert_eq!(fault.details.as_ref(), Some(&details)); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn real_request_plugin_receives_downstream_error_details_without_consuming_native_error() { + use gateway_core::{ + error::{ + ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ProviderError, + ProviderErrorKind, + }, + upstream::UpstreamSendState, + }; + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Request], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let message = "upstream fixture details: request exceeds quota"; + let bytes = b"\0opaque upstream error\xff"; + let (cache,runtime) = setup_package(vec![InstanceFixture { + id:"error-details",configuration:serde_json::json!({"mode":"downstream_error","expected_message":message,"expected_body":bytes.as_slice()}), + bindings:vec![binding(MIDDLEWARE_CONTRIBUTION,"request",0,PluginFailurePolicy::Reject)], + }],package).await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let result = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new("openai", middleware_headers(), Bytes::new()), + gateway_core::middleware::compose(Vec::new(), move |_| { + Box::pin(async move { + Err(MiddlewareError::Provider( + ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent) + .with_status(429) + .with_client_visible_upstream_error(ClientVisibleUpstreamError::new( + message, + Some("quota_fixture".into()), + Some("capacity".into()), + )) + .with_client_visible_upstream_response( + ClientVisibleUpstreamResponse::new( + 429, + Some(b"application/octet-stream".to_vec()), + Bytes::from_static(bytes), + ), + ), + )) + }) + }), + ) + .await; + let Err(MiddlewareError::Provider(error)) = result else { + panic!("native Provider failure must survive the plugin boundary"); + }; + assert_eq!(error.send_state(), UpstreamSendState::Sent); + assert_eq!( + error + .client_visible_upstream_response() + .unwrap() + .body() + .as_ref(), + bytes + ); + assert_eq!( + error.client_visible_upstream_error().unwrap().message(), + message + ); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn real_request_plugins_rewrite_settings_in_onion_order() { + use gateway_core::{policy::RateLimits, settings::ExecutionSettings, settings::SettingsValues}; + let profiles = |identity| { + BTreeMap::from([( + gateway_core::identity::ProviderKind::new("openai").unwrap(), + gateway_core::account::OpaqueProviderData::new( + serde_json::json!({"identity":identity}) + .as_object() + .unwrap() + .clone(), + ), + )]) + }; + let baseline = ExecutionSettings { + runtime: SettingsValues::new(3, 50, "smart", BTreeMap::new(), None, None) + .with_request_profiles(profiles("key-default")), + disable_fast: true, + client_limits: RateLimits { + max_concurrency: 2, + requests_per_minute: 10, + }, + timeout_ms: 60_000, + }; + let baseline_json = serde_json::to_value(&baseline).unwrap(); + let host_snapshot = gateway_core::routing::RuntimeSnapshot::new( + ConfigRevision::new(1).unwrap(), + gateway_core::settings::SettingsValues::new(1, 0, "smart", Default::default(), None, None), + vec![], + vec![], + vec![], + ) + .unwrap() + .with_settings( + &baseline + .runtime + .clone() + .with_request_profiles(profiles("host")), + ) + .unwrap(); + let configuration = gateway_core::settings::RequestSettings::new(Arc::new(host_snapshot)) + .with_execution( + &gateway_core::policy::ClientPolicy::new( + middleware_context(ClientTransport::HttpJson) + .client_key_id() + .clone(), + gateway_core::policy::PlaintextClientApiKey::new("fixture-key").unwrap(), + Arc::new( + gateway_core::routing::FrozenAccountScope::new( + Arc::default(), + gateway_core::routing::ClientRoutingScope::all_accounts(), + ) + .with_request_profiles(profiles("key-default")) + .with_disable_fast(baseline.disable_fast), + ), + true, + baseline.client_limits, + ), + baseline.timeout_ms, + ); + let mut first = baseline_json.clone(); + first["disable_fast"] = serde_json::json!(false); + first["runtime"]["request_interval_ms"] = serde_json::json!(0); + first["runtime"]["model_mappings"] = serde_json::json!({"alias":"model-one"}); + let mut last = first.clone(); + last["runtime"]["model_mappings"] = serde_json::json!({"alias":"model-two"}); + last["timeout_ms"] = serde_json::json!(90_000); + // 显式写回宿主值也要记录覆盖,否则 Key 默认值会再次生效。 + last["runtime"]["request_profiles"] = serde_json::json!({"openai":{"identity":"host"}}); + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Request], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let (cache, runtime) = setup_package(vec![ + InstanceFixture { id: "settings-first", configuration: serde_json::json!({"mode":"settings","expected_settings":baseline_json,"settings":first}), bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",0,PluginFailurePolicy::Reject)] }, + InstanceFixture { id: "settings-second", configuration: serde_json::json!({"mode":"settings","expected_settings":first,"settings":last}), bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",1,PluginFailurePolicy::Reject)] }, + InstanceFixture { id: "settings-pass", configuration: serde_json::json!({"mode":"settings","expected_settings":last}), bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",2,PluginFailurePolicy::Reject)] }, + ], package).await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + // 连续调用使用同一宿主基线,前一次插件改写不能泄漏到后一次。 + for _ in 0..2 { + let expected: ExecutionSettings = serde_json::from_value(last.clone()).unwrap(); + let next = (Downstream { + calls: Arc::default(), + closes: Arc::default(), + reads: Arc::default(), + error: false, + }) + .into_next(); + let response = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ) + .with_settings(configuration.clone()), + gateway_core::middleware::compose(Vec::new(), move |request: MiddlewareRequest| { + Box::pin(async move { + let settings = request.settings().unwrap(); + assert_eq!(settings.execution_values(), Some(expected)); + let sources = settings.inspect(); + assert_eq!( + sources["overrides"]["request_profiles"]["instance_id"], + "settings-second" + ); + assert_eq!( + sources["overrides"]["model_mappings"]["instance_id"], + "settings-second" + ); + assert_eq!( + sources["execution"]["disable_fast"]["instance_id"], + "settings-first" + ); + assert_eq!(sources["execution"]["disable_fast"]["value"], false); + assert_eq!( + sources["execution"]["timeout_ms"]["instance_id"], + "settings-second" + ); + next.run(request).await + }) + }), + ) + .await + .unwrap(); + let (_, _, _, mut body, _) = response.into_parts(); + assert!(body.next_frame().await.unwrap().is_some()); + assert!(body.next_frame().await.unwrap().is_none()); + body.close().await; + } + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn sdk_middleware_entry_registers_and_maps_a_real_process_response() { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + + let package = crate::support::package_with_contributions( + &worker, Contributions::from([crate::support::contribution( Capability::Middleware, vec![Stage::Request], @@ -1020,7 +1292,7 @@ async fn sdk_middleware_entry_registers_and_maps_a_real_process_response() { vec![InstanceFixture { id: "sdk-middleware", configuration: serde_json::json!({}), - grants: permissions.into_iter().map(grant).collect(), + bindings: vec![binding( MIDDLEWARE_CONTRIBUTION, "request", @@ -1044,12 +1316,13 @@ async fn sdk_middleware_entry_registers_and_maps_a_real_process_response() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), closes: Arc::clone(&closes), reads: Arc::clone(&reads), error: false, - }), + }) + .into_next(), ) .await .unwrap(); @@ -1098,12 +1371,13 @@ async fn sdk_middleware_entry_registers_and_maps_a_real_process_response() { middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: Arc::clone(&calls), reads: Arc::clone(&reads), closes: Arc::clone(&closes), error: false, - }), + }) + .into_next(), ) .await .unwrap(); @@ -1138,10 +1412,7 @@ async fn core_execution_resolves_the_plugin_runtime_middleware_plan() { }; let marker = environment.directory.path().join("core-middleware.jsonl"); environment - .install_plugin( - serde_json::json!({"middleware_marker": marker}), - vec![account_grant("accounts")], - ) + .install_plugin(serde_json::json!({"middleware_marker": marker})) .await; let key_secret = "sk-core-middleware-fixture"; environment @@ -1162,15 +1433,12 @@ async fn core_execution_resolves_the_plugin_runtime_middleware_plan() { #[tokio::test] async fn instances_without_data_plane_bindings_publish_neither_plan() { - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "unbound", - configuration: serde_json::json!({}), - grants: vec![], - bindings: vec![], - }], - vec![], - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id: "unbound", + configuration: serde_json::json!({}), + + bindings: vec![], + }]) .await; let generation = prepare(&runtime).await; assert!(runtime.policy_registry().resolve(&generation).is_none()); @@ -1180,6 +1448,7 @@ async fn instances_without_data_plane_bindings_publish_neither_plan() { } struct TransparencyNext { + status: u16, expected: MiddlewareRequest, headers: Vec, frames: VecDeque, @@ -1187,52 +1456,46 @@ struct TransparencyNext { struct CapabilitiesNext(Arc); -impl MiddlewareNext for CapabilitiesNext { - fn run( - self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - self.0.fetch_add(1, Ordering::SeqCst); - let body = serde_json::from_slice(request.body()).unwrap(); - let operation = Operation::Generate(GenerateRequest::from_protocol_payload( - ProtocolPayload::json_object("openai", body).unwrap(), - )); - let operation = request.apply_capabilities(operation)?; - assert!( - operation - .original_capability_requirements() - .features() - .contains(&gateway_core::operation::Feature::JsonSchema) - ); - assert!( - !operation - .capability_requirements() - .features() - .contains(&gateway_core::operation::Feature::JsonSchema) - ); - Ok(MiddlewareResponse::new( - "openai".into(), - 200, - vec![], - Box::new(TransparencyBody(VecDeque::from([MiddlewareFrame::new( - Bytes::from_static(br#"{"output":[]}"#), - MiddlewareFraming::JsonDocument, - true, - )]))), - )) +impl CapabilitiesNext { + fn into_next(self) -> MiddlewareNext { + gateway_core::middleware::compose(Vec::new(), move |request| { + Box::pin(async move { + self.0.fetch_add(1, Ordering::SeqCst); + let body = serde_json::from_slice(request.body()).unwrap(); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body).unwrap(), + )); + let operation = request.apply_capabilities(operation)?; + assert!( + operation + .original_capability_requirements() + .features() + .contains(&gateway_core::operation::Feature::JsonSchema) + ); + assert!( + !operation + .capability_requirements() + .features() + .contains(&gateway_core::operation::Feature::JsonSchema) + ); + Ok(MiddlewareResponse::new( + "openai".into(), + 200, + vec![], + Box::new(TransparencyBody(VecDeque::from([MiddlewareFrame::new( + Bytes::from_static(br#"{"output":[]}"#), + MiddlewareFraming::JsonDocument, + true, + )]))), + )) + }) }) } } #[tokio::test] -async fn sdk_capability_declarations_require_v2_authorization_and_request_stage() { - for (version, authorized, attempt) in [ - (2, true, false), - (1, true, false), - (2, false, false), - (2, true, true), - ] { +async fn sdk_capability_declarations_require_request_stage() { + for (version, attempt) in [(3, false), (3, true)] { let stage = if attempt { Stage::Attempt } else { @@ -1245,14 +1508,9 @@ async fn sdk_capability_declarations_require_v2_authorization_and_request_stage( vec!["openai".into()], ); contribution.version = version; - let permissions = if authorized { - vec![Permission::Requests] - } else { - vec![] - }; + let package = crate::support::package_with_contributions( &std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(), - permissions.clone(), Contributions::from([(capability, contribution)]), ); let (_cache, runtime) = setup_package(vec![InstanceFixture { @@ -1260,7 +1518,7 @@ async fn sdk_capability_declarations_require_v2_authorization_and_request_stage( configuration: serde_json::json!({"mode":"declare","middleware_version":version,"attempt":attempt, "body":{"model":"gpt-test","input":"return JSON matching the original schema"}, "capabilities":{"handled":["json_schema"],"required":[]}}), - grants: permissions.into_iter().map(grant).collect(), + bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, if attempt { "attempt" } else { "request" }, 0, PluginFailurePolicy::Reject)], }], package).await; let generation = prepare(&runtime).await; @@ -1273,8 +1531,8 @@ async fn sdk_capability_declarations_require_v2_authorization_and_request_stage( }; let result = plan.handle(context, MiddlewareRequest::new("openai", vec![], Bytes::from_static( br#"{"model":"gpt-test","input":"answer","text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}"#, - )), Box::new(CapabilitiesNext(calls.clone()))).await; - if version == 2 && authorized && !attempt { + )), (CapabilitiesNext(calls.clone())).into_next()).await; + if !attempt { let mut body = result.unwrap().into_parts().3; assert!(body.next_frame().await.unwrap().unwrap().terminal()); assert!(body.next_frame().await.unwrap().is_none()); @@ -1288,21 +1546,20 @@ async fn sdk_capability_declarations_require_v2_authorization_and_request_stage( } } -impl MiddlewareNext for TransparencyNext { - fn run( - self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - assert_eq!(request.protocol(), self.expected.protocol()); - assert_eq!(request.body(), self.expected.body()); - assert_eq!(request.headers(), self.expected.headers()); - Ok(MiddlewareResponse::new( - "openai".into(), - 200, - self.headers, - Box::new(TransparencyBody(self.frames)), - )) +impl TransparencyNext { + fn into_next(self) -> MiddlewareNext { + gateway_core::middleware::compose(Vec::new(), move |request| { + Box::pin(async move { + assert_eq!(request.protocol(), self.expected.protocol()); + assert_eq!(request.body(), self.expected.body()); + assert_eq!(request.headers(), self.expected.headers()); + Ok(MiddlewareResponse::new( + "openai".into(), + self.status, + self.headers, + Box::new(TransparencyBody(self.frames)), + )) + }) }) } } @@ -1319,16 +1576,11 @@ impl MiddlewareBody for TransparencyBody { } } -async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { +async fn assert_sdk_transparency(mode: &'static str) { let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); - let permissions = if authorized { - vec![Permission::Requests] - } else { - vec![] - }; + let package = crate::support::package_with_contributions( &worker, - permissions.clone(), Contributions::from([crate::support::contribution( Capability::Middleware, vec![Stage::Request], @@ -1340,7 +1592,7 @@ async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { vec![InstanceFixture { id: "transparency", configuration: serde_json::json!({"mode": mode}), - grants: permissions.into_iter().map(grant).collect(), + bindings: vec![binding( MIDDLEWARE_CONTRIBUTION, "request", @@ -1369,13 +1621,19 @@ async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { br#" { "z": 18446744073709551615, "unknown": { "x": 1e2 }, "a": "\u0061" } "#, ); let cases = [ - (ClientTransport::HttpJson, MiddlewareFraming::JsonDocument, vec![json.clone()]), - (ClientTransport::HttpSse, MiddlewareFraming::SseEvent, vec![ + (ClientTransport::HttpJson, 200, MiddlewareFraming::JsonDocument, vec![json.clone()]), + (ClientTransport::WebSocket, 200, MiddlewareFraming::JsonDocument, vec![json.clone(), json.clone()]), + (ClientTransport::WebSocket, 503, MiddlewareFraming::JsonDocument, vec![json.clone()]), + (ClientTransport::InternalProbe, 200, MiddlewareFraming::JsonDocument, vec![json.clone()]), + (ClientTransport::InternalPlugin, 200, MiddlewareFraming::JsonDocument, vec![json.clone()]), + (ClientTransport::HttpJson, 503, MiddlewareFraming::RawBytes, vec![Bytes::from_static(b"unavailable")]), + (ClientTransport::HttpSse, 503, MiddlewareFraming::RawBytes, vec![Bytes::from_static(b"unavailable")]), + (ClientTransport::HttpSse, 200, MiddlewareFraming::SseEvent, vec![ Bytes::from_static(b": keep-alive\r\nevent: response.created\r\ndata: { \"z\": 1, \"a\": 2 }\r\n\r\n"), Bytes::from_static(b"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{}}\n\n"), ]), ]; - for (transport, framing, payloads) in cases { + for (transport, status, framing, payloads) in cases { for request_body in [json.clone(), Bytes::new()] { let request = MiddlewareRequest::new("openai", headers.clone(), request_body.clone()); let mut expected_headers = headers.clone(); @@ -1390,7 +1648,20 @@ async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { Bytes::from_static(b"active"), )); } + if mode == "credentials" { + expected_headers.retain(|header| header.name() != "authorization"); + expected_headers.push(MiddlewareHeader::new( + "authorization", + Bytes::from_static(b"Bearer rewritten"), + )); + expected_response_headers.retain(|header| header.name() != "set-cookie"); + expected_response_headers.push(MiddlewareHeader::new( + "set-cookie", + Bytes::from_static(b"rewritten=true"), + )); + } let next = TransparencyNext { + status, expected: MiddlewareRequest::new("openai", expected_headers, request_body), headers: response_headers.clone(), frames: payloads @@ -1402,12 +1673,12 @@ async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { .collect(), }; let response = plan - .handle(middleware_context(transport), request, Box::new(next)) + .handle(middleware_context(transport), request, next.into_next()) .await .unwrap(); - let (protocol, status, actual_headers, mut body, _) = response.into_parts(); + let (protocol, actual_status, actual_headers, mut body, _) = response.into_parts(); assert_eq!(protocol, "openai"); - assert_eq!(status, 200); + assert_eq!(actual_status, status); assert_eq!(actual_headers, expected_response_headers); for (index, expected) in payloads.iter().enumerate() { let frame = body.next_frame().await.unwrap().unwrap(); @@ -1428,19 +1699,20 @@ async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { } #[tokio::test] -async fn sdk_passthrough_preserves_original_bytes_and_hidden_headers() { - assert_sdk_transparency("passthrough", true).await; - assert_sdk_transparency("passthrough", false).await; +async fn sdk_passthrough_preserves_original_bytes_and_all_headers() { + assert_sdk_transparency("passthrough").await; + assert_sdk_transparency("passthrough").await; } #[tokio::test] async fn sdk_read_only_inspection_preserves_original_bytes_and_event_order() { - assert_sdk_transparency("inspect", true).await; + assert_sdk_transparency("inspect").await; } #[tokio::test] async fn sdk_header_mutations_preserve_original_body_and_other_headers() { - assert_sdk_transparency("headers", true).await; + assert_sdk_transparency("headers").await; + assert_sdk_transparency("credentials").await; } #[tokio::test] @@ -1453,15 +1725,12 @@ async fn a_failed_plugin_rejects_only_its_bound_models_and_preserves_delegate_po failure_policy.clone(), ); mount.models = vec!["plugin-required".into()]; - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "failed-router", - configuration: serde_json::json!({"startup":"fail"}), - grants: vec![], - bindings: vec![mount], - }], - vec![], - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id: "failed-router", + configuration: serde_json::json!({"startup":"fail"}), + + bindings: vec![mount], + }]) .await; let generation = prepare(&runtime).await; let context = policy_context(&runtime, generation.clone(), "req_isolated_failure"); @@ -1500,15 +1769,12 @@ async fn a_failed_middleware_preserves_scope_and_the_configured_failure_policy() ] { let mut mount = binding(MIDDLEWARE_CONTRIBUTION, "request", 0, failure_policy); mount.models = vec![model.into()]; - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "failed-middleware", - configuration: serde_json::json!({"startup":"fail"}), - grants: vec![], - bindings: vec![mount], - }], - vec![], - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id: "failed-middleware", + configuration: serde_json::json!({"startup":"fail"}), + + bindings: vec![mount], + }]) .await; let generation = prepare(&runtime).await; let plan = runtime.middleware_registry().resolve(&generation).unwrap(); @@ -1521,12 +1787,13 @@ async fn a_failed_middleware_preserves_scope_and_the_configured_failure_policy() middleware_headers(), Bytes::from_static(br#"{"input":"hello"}"#), ), - Box::new(Downstream { + (Downstream { calls: calls.clone(), reads: Arc::default(), closes: Arc::default(), error: false, - }), + }) + .into_next(), ) .await; assert_eq!(response.is_ok(), expected_calls == 1); @@ -1543,51 +1810,47 @@ struct BodyCheckingNext { expected: Bytes, } -impl MiddlewareNext for BodyCheckingNext { - fn run( - self: Box, - request: MiddlewareRequest, - ) -> BoxFuture<'static, Result> { - Box::pin(async move { - assert!( - request.body() == &self.expected, - "request body changed unexpectedly" - ); - self.calls.fetch_add(1, Ordering::Relaxed); - Ok(MiddlewareResponse::new( - "openai".to_owned(), - 200, - vec![], - Box::new(OneFrameBody { - reads: Arc::default(), - closes: Arc::default(), - frame: Some(MiddlewareFrame::new( - Bytes::from_static(b"{}"), - MiddlewareFraming::JsonDocument, - true, - )), - }), - )) +impl BodyCheckingNext { + fn into_next(self) -> MiddlewareNext { + gateway_core::middleware::compose(Vec::new(), move |request| { + Box::pin(async move { + assert!( + request.body() == &self.expected, + "request body changed unexpectedly" + ); + self.calls.fetch_add(1, Ordering::Relaxed); + Ok(MiddlewareResponse::new( + "openai".to_owned(), + 200, + vec![], + Box::new(OneFrameBody { + reads: Arc::default(), + closes: Arc::default(), + frame: Some(MiddlewareFrame::new( + Bytes::from_static(b"{}"), + MiddlewareFraming::JsonDocument, + true, + )), + }), + )) + }) }) } } #[tokio::test] async fn middleware_preserves_large_body_and_following_requests() { - let (cache, runtime) = setup( - vec![InstanceFixture { - id: "large-body", - configuration: serde_json::json!({}), - grants: vec![grant(Permission::Requests)], - bindings: vec![binding( - MIDDLEWARE_CONTRIBUTION, - "request", - 0, - PluginFailurePolicy::Reject, - )], - }], - vec![Permission::Requests], - ) + let (cache, runtime) = setup(vec![InstanceFixture { + id: "large-body", + configuration: serde_json::json!({}), + + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "request", + 0, + PluginFailurePolicy::Reject, + )], + }]) .await; let generation = prepare(&runtime).await; let plan = runtime.middleware_registry().resolve(&generation).unwrap(); @@ -1602,10 +1865,11 @@ async fn middleware_preserves_large_body_and_following_requests() { .handle( middleware_context(ClientTransport::HttpSse), MiddlewareRequest::new("openai", vec![], payload.clone()), - Box::new(BodyCheckingNext { + (BodyCheckingNext { calls: Arc::clone(&calls), expected: payload, - }), + }) + .into_next(), ) .await .unwrap(); diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mounts.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mounts.rs new file mode 100644 index 000000000..c4da70111 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mounts.rs @@ -0,0 +1,167 @@ +use super::*; +use gateway_core::middleware::{compose, http, service, websocket}; +use http_body_util::BodyExt as _; + +struct Sender; +impl websocket::Sender for Sender { + fn send(&self, _: websocket::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async { panic!("forwarded messages use next") }) + } +} + +#[tokio::test] +async fn mixed_mounts_keep_independent_order_and_fresh_continuations() { + let records_dir = tempfile::tempdir().unwrap(); + let records = records_dir.path().join("calls"); + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![ + Stage::Http, + Stage::WebSocket, + Stage::Service, + Stage::Request, + Stage::Attempt, + ], + vec![ + "http".into(), + "websocket".into(), + "service".into(), + "openai".into(), + ], + vec![ + "http".into(), + "websocket".into(), + "service".into(), + "openai".into(), + ], + )]), + ); + let (_cache, runtime) = setup_package( + [ + ("first", vec![("http", 30), ("websocket", -10)]), + ("second", vec![("http", -10), ("websocket", 30)]), + ( + "other", + vec![("service", 0), ("request", 0), ("attempt", 0)], + ), + ] + .into_iter() + .map(|(id, mounts)| InstanceFixture { + id, + configuration: serde_json::json!({"mode":"mixed_mounts","records":records}), + bindings: mounts + .into_iter() + .map(|(stage, order)| { + binding( + MIDDLEWARE_CONTRIBUTION, + stage, + order, + PluginFailurePolicy::Reject, + ) + }) + .collect(), + }) + .collect(), + package, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + assert!(plan.has_http() && plan.has_websocket() && plan.has_service()); + let mut expected = Vec::new(); + for direction in [ + websocket::Direction::Incoming, + websocket::Direction::Outgoing, + ] { + let response = plan + .handle_http( + http::Context { + plugin_instance_id: None, + request_id: "request".into(), + call_id: "call".into(), + parent_call_id: None, + extensions: Default::default(), + cancellation: CancellationToken::new(), + plan: Some(plan.clone()), + }, + http::Request::new(http::empty_body()), + compose(Vec::new(), |request: http::Request| { + Box::pin(async { Ok(http::Response::new(request.into_body())) }) + }), + ) + .await + .unwrap(); + assert!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty() + ); + expected.extend([ + "enter:Http:second", + "enter:Http:first", + "exit:Http:first", + "exit:Http:second", + ]); + let message = plan + .handle_websocket( + websocket::Context { + plan: Some(plan.clone()), + connection_id: "connection".into(), + direction, + headers: Arc::from([]), + cancellation: CancellationToken::new(), + sender: Arc::new(Sender), + }, + websocket::Message { + kind: websocket::Kind::Binary, + payload: Bytes::from_static(b"payload"), + }, + compose(Vec::new(), |message| Box::pin(async { Ok(Some(message)) })), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(message.payload, "payload"); + expected.extend([ + "enter:WebSocket:first", + "enter:WebSocket:second", + "exit:WebSocket:second", + "exit:WebSocket:first", + ]); + } + let value = plan + .handle_service( + service::Context { + operation: "fixture.operation", + request_id: "request".into(), + call_id: "service".into(), + parent_call_id: None, + extensions: Default::default(), + cancellation: CancellationToken::new(), + plan: plan.clone(), + }, + serde_json::json!({"value":42}), + compose(Vec::new(), |value| Box::pin(async { Ok(value) })), + ) + .await + .unwrap(); + assert_eq!(value["value"], 42); + expected.extend(["enter:Service:other", "exit:Service:other"]); + assert_eq!( + std::fs::read_to_string(records) + .unwrap() + .lines() + .collect::>(), + expected + ); + drop(plan); + drop(generation); + runtime.shutdown().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs new file mode 100644 index 000000000..b8f2f95bf --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/service/mod.rs @@ -0,0 +1,451 @@ +use crate::support::environment::{Environment, mutation}; +use gateway_admin::model::{AdminErrorKind, settings::ReplaceRuntimeSettings}; +use serde_json::json; + +#[tokio::test] +async fn plugin_error_details_survive_public_service_composition() { + let Some(environment) = Environment::create().await else { + return; + }; + let (runtime, core) = environment + .plugin(json!({"service":true,"mode":"fault"})) + .await; + let admin = environment.bind_admin_accounts(&runtime, &core).await; + let services = admin.services(); + runtime.bind_services(&services.public_services()).unwrap(); + let error = services + .public_services() + .call("settings.load", json!(null)) + .await + .unwrap_err(); + assert_eq!(error.message, "service plugin fixture error"); + let details = error.details.unwrap(); + assert_eq!(details["code"], "capacity"); + assert_eq!( + details["details"], + json!({"opaque":"unfiltered","empty":"","zero":0,"disabled":false,"missing":null}) + ); + runtime.shutdown().await; +} + +#[tokio::test] +async fn explicit_service_dispatch_composes_once_and_preserves_native_transactions() { + let Some(environment) = Environment::create().await else { + return; + }; + let records = environment.directory.path().join("services.jsonl"); + let (runtime, core) = environment + .plugin(json!({"service":true,"mode":"rewrite","records":records})) + .await; + let admin = environment.bind_admin_accounts(&runtime, &core).await; + let services = admin.services(); + runtime.bind_services(&services.public_services()).unwrap(); + + let original = environment + .store + .admin_ports() + .settings() + .load_runtime_settings() + .await + .unwrap(); + let visible = services.settings().load().await.unwrap(); + assert_eq!(visible.request_interval_ms, original.request_interval_ms); + assert_eq!(visible.config_revision, original.config_revision); + let through_registry = services + .public_services() + .call("settings.load", json!(null)) + .await + .unwrap(); + let typed: gateway_plugin_sdk::call::services::settings::RuntimeSettings = + serde_json::from_value(through_registry).unwrap(); + assert_eq!(typed.request_interval_ms, 4321); + // 返回层改写不是持久化写入;原 owner 保留真实配置事实。 + assert_eq!( + environment + .store + .admin_ports() + .settings() + .load_runtime_settings() + .await + .unwrap() + .request_interval_ms, + original.request_interval_ms + ); + + let mut command = ReplaceRuntimeSettings::from(original); + command.request_interval_ms = 999; + let saved = services + .public_services() + .call( + "settings.replace", + serde_json::to_value((mutation(), command.clone())).unwrap(), + ) + .await + .unwrap(); + let saved: gateway_admin::model::settings::RuntimeSettings = + serde_json::from_value(saved).unwrap(); + assert_eq!(saved.request_interval_ms, 1234); + assert!(saved.config_revision > command.expected_revision); + let current = environment + .store + .admin_ports() + .settings() + .load_runtime_settings() + .await + .unwrap(); + assert_eq!(current.request_interval_ms, 1234); + assert_eq!( + core.snapshots() + .snapshot_for_diagnostics() + .unwrap() + .revision() + .get(), + saved.config_revision.get() + ); + let error = services + .settings() + .replace(&mutation(), command.clone()) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + let error = services + .public_services() + .call( + "settings.replace", + serde_json::to_value((mutation(), command)).unwrap(), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "conflict"); + assert!(!error.message.is_empty()); + + // 组合位于主动调用入口;内部 pricing 不重复进入插件链。 + let error = services + .public_services() + .call( + "settings.update_pricing", + serde_json::to_value(( + mutation(), + gateway_admin::model::pricing::UpdatePricing { + provider: "missing-provider".into(), + models: vec!["model".into()], + change: gateway_admin::model::pricing::PricingChange::Reset, + }, + )) + .unwrap(), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, "invalid"); + let records: Vec = std::fs::read_to_string(&records) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + records + .iter() + .filter(|record| record["operation"] == "settings.update_pricing") + .count(), + 1 + ); + assert!( + !records + .iter() + .any(|record| record["operation"] == "settings.pricing") + ); + + drop(services); + drop(admin); + drop(core); + runtime.shutdown().await; + environment.close().await; +} + +#[tokio::test] +async fn service_middleware_can_recover_a_native_error_without_changing_the_default_service() { + let Some(environment) = Environment::create().await else { + return; + }; + let (runtime, core) = environment + .plugin(json!({"service":true,"mode":"recover"})) + .await; + let admin = environment.bind_admin_accounts(&runtime, &core).await; + let services = admin.services(); + runtime.bind_services(&services.public_services()).unwrap(); + assert!( + services + .settings() + .preview_client_profile("missing-provider", None) + .await + .is_err() + ); + let recovered = services + .public_services() + .call( + "settings.preview_client_profile", + json!(["missing-provider", null]), + ) + .await + .unwrap(); + assert_eq!(recovered, json!({})); + assert_eq!( + services + .public_services() + .call("settings.admin_api_key_exists", json!(null)) + .await + .unwrap(), + true + ); + assert!(!services.settings().admin_api_key_exists().await.unwrap()); + assert!( + !environment + .store + .admin_ports() + .settings() + .admin_api_key_exists() + .await + .unwrap() + ); + assert!( + services + .public_services() + .call("settings.unknown", json!(null)) + .await + .is_err() + ); + drop(services); + drop(admin); + drop(core); + runtime.shutdown().await; + environment.close().await; +} + +#[tokio::test] +async fn cancelling_explicit_service_call_releases_plugin_handler_before_shutdown() { + let Some(environment) = Environment::create().await else { + return; + }; + let records = environment.directory.path().join("blocked.jsonl"); + let (runtime, core) = environment + .plugin(json!({"service":true,"mode":"block","records":records})) + .await; + let admin = environment.bind_admin_accounts(&runtime, &core).await; + let services = admin.services(); + runtime.bind_services(&services.public_services()).unwrap(); + let registry = services.public_services(); + let task = tokio::spawn(async move { registry.call("settings.load", json!(null)).await }); + let wait_for = |path: std::path::PathBuf| async move { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !path.exists() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("plugin lifecycle signal"); + }; + wait_for(records.clone()).await; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + wait_for(records.with_extension("closed")).await; + drop(services); + drop(admin); + drop(core); + runtime.shutdown().await; + environment.close().await; +} + +#[tokio::test] +async fn cli_service_callback_uses_the_same_public_settings_and_plugin_chain() { + use std::sync::Arc; + struct Pricing; + #[async_trait::async_trait] + impl gateway_admin::ports::pricing::PricingSource for Pricing { + async fn fetch( + &self, + ) -> Result< + gateway_admin::model::pricing::PricingSyncPreview, + gateway_admin::model::AdminError, + > { + unreachable!("reading settings does not fetch external pricing") + } + } + let Some(environment) = Environment::create_command().await else { + return; + }; + environment.install_plugin(json!({ + "plugin_id":"test.command-source", + "command_registration":{"commands":[{"name":"settings","description":"Read public settings"}]}, + "command_service_request":{"operation":"settings.load","input":null}, + })).await; + environment + .install_plugin(json!({"service":true,"mode":"rewrite"})) + .await; + let (runtime, core) = environment.command_plane().await; + let settings = gateway_admin::initialize_settings( + environment.store.admin_ports().settings(), + core.snapshot_control(), + crate::support::native::admin_registry(), + Arc::new(Pricing), + ); + let mut registry = gateway_admin::service::Registry::new({ + let snapshots = core.snapshots(); + let middleware = runtime.middleware_registry(); + Arc::new(move || middleware.resolve(snapshots.snapshot_for_diagnostics()?.extensions()?)) + }); + registry.register_settings(&settings).unwrap(); + let registry = Arc::new(registry); + runtime.bind_services(®istry).unwrap(); + let instances = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let command = instances + .instances + .iter() + .find(|instance| instance.configuration.get("command_registration").is_some()) + .unwrap(); + let commands = runtime.prepare_command_line().await.unwrap(); + let output = commands + .execute(&command.id, "settings", &[]) + .await + .unwrap(); + let result: gateway_plugin_sdk::call::services::Response = + serde_json::from_str(&output.stdout).unwrap(); + let returned: gateway_plugin_sdk::call::services::settings::RuntimeSettings = + serde_json::from_value(result.unwrap()).unwrap(); + assert_eq!(returned.request_interval_ms, 4321); + assert_ne!( + environment + .store + .admin_ports() + .settings() + .load_runtime_settings() + .await + .unwrap() + .request_interval_ms, + 4321 + ); + commands.shutdown().await; + drop(core); + runtime.shutdown().await; + environment.close().await; +} + +#[tokio::test] +async fn service_chain_preserves_onion_order_parent_scope_and_native_results_without_a_database() { + use super::*; + use gateway_core::middleware::{compose, service as core}; + + let directory = tempfile::tempdir().unwrap(); + let records = directory.path().join("onion.jsonl"); + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Service], + vec!["service".into()], + vec!["service".into()], + )]), + ); + let (cache, runtime) = setup_package( + [("inner", 20), ("ancestor", 0), ("outer", 10)] + .into_iter() + .map(|(id, order)| InstanceFixture { + id, + configuration: json!({"service":true,"mode":"onion","records":records}), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "service", + order, + PluginFailurePolicy::Reject, + )], + }) + .collect(), + package, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let input = json!({"opaque":[null,false,0,""]}); + for reject in [false, true] { + let observed = calls.clone(); + let expected_input = input.clone(); + let expected = if reject { + Err(core::Error { + kind: "fixture".into(), + message: "native error".into(), + details: Some(input.clone()), + }) + } else { + Ok(input.clone()) + }; + let result = expected.clone(); + let actual = plan + .handle_service( + core::Context { + operation: "fixture.echo", + request_id: "request".into(), + call_id: "child".into(), + parent_call_id: Some("parent".into()), + cancellation: CancellationToken::new(), + extensions: ExtensionCallScope::default() + .extending("ancestor".into()) + .unwrap(), + plan: plan.clone(), + }, + input.clone(), + compose(Vec::new(), move |input| { + Box::pin(async move { + observed.fetch_add(1, Ordering::SeqCst); + assert_eq!(input, expected_input); + result + }) + }), + ) + .await; + assert_eq!(actual, expected); + } + assert_eq!(calls.load(Ordering::SeqCst), 2); + let events = std::fs::read_to_string(records) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(events.len(), 8); + for round in events.chunks_exact(4) { + let phases = round + .iter() + .map(|event| { + ( + event["instance"].as_str().unwrap(), + event["phase"].as_str().unwrap(), + ) + }) + .collect::>(); + assert_eq!( + phases, + [ + ("outer", "enter"), + ("inner", "enter"), + ("inner", "exit"), + ("outer", "exit") + ] + ); + for event in round { + assert_eq!(event["operation"], "fixture.echo"); + assert_eq!(event["request_id"], "request"); + assert_eq!(event["call_id"], "child"); + assert_eq!(event["parent_call_id"], "parent"); + } + } + drop(plan); + drop(generation); + runtime.shutdown().await; + drop(cache); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/websocket/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/websocket/mod.rs new file mode 100644 index 000000000..6ad1e9802 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/websocket/mod.rs @@ -0,0 +1,146 @@ +use super::*; +use gateway_core::middleware::{compose, websocket as core}; + +#[derive(Default)] +struct Sender(std::sync::Mutex>); +impl core::Sender for Sender { + fn send(&self, message: core::Message) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + self.0.lock().unwrap().push(message); + Ok(()) + }) + } +} +async fn websocket_plugin( + mode: &str, +) -> ( + tempfile::TempDir, + PluginRuntime, + ExtensionSetReference, + gateway_core::engine::middleware::FrozenMiddlewarePlan, +) { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::WebSocket], + vec!["websocket".into()], + vec!["websocket".into()], + )]), + ); + let (cache, runtime) = setup_package( + vec![InstanceFixture { + id: "websocket-plugin", + configuration: serde_json::json!({"websocket":true,"mode":mode}), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "websocket", + 0, + PluginFailurePolicy::Reject, + )], + }], + package, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + assert!(plan.has_websocket()); + (cache, runtime, generation, plan) +} +fn context(direction: core::Direction, sender: Arc) -> core::Context { + core::Context { + plan: None, + connection_id: "ws-fixture".into(), + direction, + headers: Arc::from([MiddlewareHeader::new( + "authorization", + Bytes::from_static(b"Bearer fixture-secret"), + )]), + cancellation: CancellationToken::new(), + sender, + } +} +#[tokio::test] +async fn real_plugin_passes_large_binary_messages_by_handle_in_both_directions() { + let (_cache, runtime, generation, plan) = websocket_plugin("passthrough").await; + for direction in [core::Direction::Incoming, core::Direction::Outgoing] { + let payload = Bytes::from(vec![255; 20 * 1024 * 1024]); + let pointer = payload.as_ptr(); + let message = core::Message { + kind: core::Kind::Binary, + payload, + }; + let result = plan + .handle_websocket( + context(direction, Arc::default()), + message, + compose(Vec::new(), |message| Box::pin(async { Ok(Some(message)) })), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result.kind, core::Kind::Binary); + assert_eq!(result.payload.len(), 20 * 1024 * 1024); + assert_eq!( + result.payload.as_ptr(), + pointer, + "unread payload stays in the host" + ); + } + drop(plan); + drop(generation); + runtime.shutdown().await; +} +#[tokio::test] +async fn real_plugin_consumes_unknown_control_and_sends_a_binary_reply() { + let (_cache, runtime, generation, plan) = websocket_plugin("control").await; + let sender = Arc::new(Sender::default()); + let result = plan + .handle_websocket( + context(core::Direction::Incoming, sender.clone()), + core::Message { + kind: core::Kind::Text, + payload: Bytes::from_static(b"custom.control"), + }, + compose(Vec::new(), |_| { + Box::pin(async { panic!("consumed control must not reach protocol parser") }) + }), + ) + .await + .unwrap(); + assert!(result.is_none()); + { + let sent = sender.0.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].payload.as_ref(), &[0, 255, 1]); + } + drop(plan); + drop(generation); + runtime.shutdown().await; +} +#[tokio::test] +async fn real_plugin_rewrites_payload_before_next() { + let (_cache, runtime, generation, plan) = websocket_plugin("map").await; + let result = plan + .handle_websocket( + context(core::Direction::Outgoing, Arc::default()), + core::Message { + kind: core::Kind::Text, + payload: Bytes::from_static(b"opaque output"), + }, + compose(Vec::new(), |message: core::Message| { + Box::pin(async { + assert_eq!(message.payload, "OPAQUE OUTPUT"); + Ok(Some(message)) + }) + }), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result.payload, "OPAQUE OUTPUT"); + drop(plan); + drop(generation); + runtime.shutdown().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs index b6c84156e..294c5c7a1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs @@ -189,6 +189,7 @@ async fn management_needs_no_binding_and_rejects_stale_execution_identity_config account_group_ids: Vec::new(), provider_ids: Vec::new(), models: Vec::new(), + event: None, identity_bindings: Vec::new(), }]; assert!( @@ -208,7 +209,7 @@ async fn management_needs_no_binding_and_rejects_stale_execution_identity_config async fn registration_with_a_changed_contribution_id_is_rejected() { let (cache, store, runtime) = super::setup_with_contributions(Contributions::from([crate::support::contribution( - Capability::Usage, + Capability::Observer, vec![Stage::Observation], Vec::new(), Vec::new(), @@ -659,8 +660,8 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures() let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::json!({ "startup_marker":marker, - "exit_after_ready_delays_ms":[null,15,15,15], - "exit_after_ready_signals":[old_exit,null,null,null], + "startup_failures":[false,true,true,true], + "exit_after_ready_signals":[old_exit], }); let mut other = snapshot.instances[0].clone(); other.id = "other-instance".into(); @@ -671,6 +672,9 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures() .await .expect("published generation"); assert!(published.is_ready()); + // 旧代次明确越过稳定窗口;新代次在握手前失败,运行时间固定为零。 + // 不依赖 15 ms 退出与注册完成的竞速,也不让调度延迟重置新失败预算。 + tokio::time::sleep(restart_circuit.stability_window).await; let mut failed_candidate = None; for revision in 2..=4 { @@ -679,8 +683,8 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures() snapshot.instances[1].configuration = serde_json::json!({"revision":revision}); let candidate = PluginPreparation::prepare(&runtime, snapshot.clone()) .await - .expect("newer candidate starts before its controlled exit"); - wait_until_unready(&candidate).await; + .expect("the unrelated instance starts while the newer incarnation fails"); + assert_eq!(startup_count(&marker), revision as usize); drop(failed_candidate.replace(candidate)); assert!(published.is_ready(), "the older generation remains healthy"); } diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs b/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs index 3426bf0f7..b86596dd4 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs @@ -223,7 +223,7 @@ async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namesp let cache = tempfile::tempdir().unwrap(); let marker = cache.path().join("state.jsonl"); let contributes = Contributions::from([crate::support::contribution( - Capability::RequestLifecycle, + Capability::Observer, vec![Stage::Observation], vec![], vec![], @@ -244,7 +244,6 @@ async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namesp }; let archive = crate::support::package_with_contributions_and_state( crate::support::worker(), - vec![], contributes, vec![state_namespace], ); @@ -269,9 +268,9 @@ async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namesp "state_marker": marker, }), secrets: BTreeMap::new(), - grants: vec![], + bindings: vec![PluginCapabilityBinding { - contribution: "test.example.requestLifecycle".into(), + contribution: "test.example.observer".into(), stage: "observation".into(), order: 0, failure_policy: PluginFailurePolicy::Observe, @@ -279,6 +278,7 @@ async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namesp account_group_ids: vec![], provider_ids: vec!["openai".into()], models: vec!["gpt-state".into()], + event: Some("request_completed".into()), identity_bindings: vec![], }], revision: Revision::new(1).unwrap(), 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 89d8e3be6..fb7991026 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs @@ -9,9 +9,7 @@ use std::{ use bytes::Bytes; use futures::{StreamExt as _, future::BoxFuture}; -use gateway_admin::model::plugins::instances::{ - PluginCapabilityBinding, PluginFailurePolicy, PluginPermissionGrant, -}; +use gateway_admin::model::plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy}; use gateway_core::{ account::{AccountSelectionPolicy, CredentialRevision, OutboundProxy, ProviderAccountId}, engine::{ @@ -31,7 +29,7 @@ use gateway_core::{ runtime::extensions::{ExtensionPreparationPort, ExtensionSetReference}, upstream::{UpstreamSendState, UpstreamTransport}, }; -use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; use serde_json::{Value, json}; use wiremock::{ Mock, MockServer, ResponseTemplate, @@ -102,7 +100,6 @@ async fn setup( Arc, gateway_plugin_runtime::PluginRuntime, ) { - let permissions = vec![Permission::Requests, Permission::UpstreamConnections]; let contributes = Contributions::from([crate::support::contribution( Capability::UpstreamAdapter, vec![Stage::Upstream], @@ -110,17 +107,11 @@ async fn setup( vec!["openai".into()], )]); let (cache, store, runtime) = - super::setup_with_permissions(contributes, Default::default(), permissions.clone()).await; + super::setup_with_contributions_and_restart_circuit(contributes, Default::default()).await; { let mut snapshot = store.snapshot.lock().unwrap(); let instance = &mut snapshot.instances[0]; instance.configuration = config; - instance.grants = permissions - .into_iter() - .map(|permission| PluginPermissionGrant { - permission: permission.as_str().into(), - }) - .collect(); instance.bindings = vec![PluginCapabilityBinding { contribution: "test.example.upstreamAdapter".into(), stage: "upstream".into(), @@ -130,16 +121,11 @@ async fn setup( account_group_ids: vec![], provider_ids: vec!["openai".into()], models: vec![], + event: None, identity_bindings: vec![], }]; } - ( - cache, - store, - runtime.with_network_policy( - gateway_host::outbound::NetworkPolicy::new(&["127.0.0.0/8".into()]).unwrap(), - ), - ) + (cache, store, runtime) } fn context(generation: &ExtensionSetReference, key: &str) -> AttemptContext { @@ -335,11 +321,11 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() { } #[tokio::test] -async fn adapter_rejects_forged_auth_headers_and_cross_key_continuation_before_send() { +async fn adapter_overrides_auth_headers_and_rejects_stale_continuation() { let server = MockServer::start().await; Mock::given(method("POST")) .respond_with(ResponseTemplate::new(200).set_body_string("{}")) - .expect(1) + .expect(2) .mount(&server) .await; let (cache, _, runtime) = setup(configuration(&server.uri(), false)).await; @@ -375,13 +361,17 @@ async fn adapter_rejects_forged_auth_headers_and_cross_key_continuation_before_s let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) .await .unwrap(); - let error = execute(&generation, Account::new(), "key-one", None) - .next() - .await - .unwrap() - .err() - .unwrap(); - assert_eq!(error.send_state(), UpstreamSendState::NotSent); + let mut stream = execute(&generation, Account::new(), "key-one", None); + while let Some(event) = stream.next().await { + event.unwrap(); + } + drop(stream); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1].headers.get("authorization").unwrap(), + "injected" + ); drop(generation); runtime.shutdown().await; super::wait_until_empty(cache.path()).await; @@ -533,6 +523,62 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle .unwrap(); } +#[tokio::test] +async fn websocket_adapter_can_send_while_a_read_callback_is_pending() { + use futures::SinkExt as _; + use tokio_tungstenite::tungstenite::Message; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + assert_eq!( + socket.next().await.unwrap().unwrap(), + Message::Text("control".into()) + ); + socket.send(Message::Text("{}".into())).await.unwrap(); + assert!(socket.next().await.is_none_or(|result| result.is_err())); + }); + let mut config = configuration(&base, false); + config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket"); + config["upstream_events"][1]["continuation"]["scope"] = json!("connection_local"); + config["upstream_callbacks"] = json!([ + {"method":"host.upstream.websocket.open","params":{"path":"responses","headers":[]}}, + [ + {"method":"host.upstream.websocket.read","params":{}}, + {"method":"host.upstream.websocket.send","params":{"kind":"text"},"body":"control"} + ] + ]); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&generation, Account::new(), "key-one", None); + let mut continuation = None; + tokio::time::timeout(Duration::from_secs(3), async { + while let Some(event) = stream.next().await { + continuation = event.unwrap().take_session_update().or(continuation.take()); + } + }) + .await + .unwrap(); + assert!( + continuation + .unwrap() + .extension_owner() + .unwrap() + .connection_local + ); + drop(stream); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); +} + #[tokio::test] async fn cancelling_adapter_body_closes_pending_websocket_and_releases_selected_account() { use tokio_tungstenite::tungstenite::Message; @@ -617,7 +663,7 @@ async fn usage_without_response_model_still_uses_native_account_pricing() { #[tokio::test] async fn core_published_adapter_uses_normal_attempt_usage_and_cost_ledger() { - use crate::support::environment::{Environment, account_grant}; + use crate::support::environment::Environment; let Some(mut environment) = Environment::create_command().await else { eprintln!("SKIP: plugin integration environment absent"); return; @@ -641,13 +687,7 @@ async fn core_published_adapter_uses_normal_attempt_usage_and_cost_ledger() { ) .await; environment - .install_plugin( - configuration(&server.uri(), false), - vec![ - account_grant("requests"), - account_grant("upstream_connections"), - ], - ) + .install_plugin(configuration(&server.uri(), false)) .await; environment.install_plugin(json!({ "plugin_id":"test.adapter-ledger-caller", @@ -657,7 +697,7 @@ async fn core_published_adapter_uses_normal_attempt_usage_and_cost_ledger() { "body":{"model":crate::support::native::MODEL,"input":"adapter ledger"} }, "command_result":{"stdout":"ok","stderr":"","exit_code":0} - }), vec![account_grant("models"), account_grant("accounts")]).await; + })).await; let (runtime, core) = environment.command_plane().await; let snapshot = environment .store @@ -790,3 +830,61 @@ async fn adapter_reconfiguration_disable_and_rollback_keep_inflight_generation_a super::wait_until_empty(cache.path()).await; assert_eq!(Arc::strong_count(&account), 1); } + +#[tokio::test] +async fn upstream_adapter_can_dispatch_http_after_its_instance_entered_the_scope() { + struct Http(Arc); + impl gateway_core::middleware::http::Dispatcher for Http { + fn dispatch( + &self, + context: gateway_core::middleware::http::Context, + request: gateway_core::middleware::http::Request, + ) -> BoxFuture< + 'static, + Result< + gateway_core::middleware::http::Response, + gateway_core::engine::middleware::MiddlewareError, + >, + > { + assert!( + context + .extensions + .contains(context.plugin_instance_id.as_deref().unwrap()) + ); + assert_eq!(context.extensions.len(), 1); + assert_eq!(request.uri(), "/api/admin/settings"); + self.0.fetch_add(1, Ordering::SeqCst); + Box::pin(async { + Ok(gateway_core::middleware::http::Response::new( + gateway_core::middleware::http::empty_body(), + )) + }) + } + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{\"id\":\"response-one\"}")) + .mount(&server) + .await; + let mut config = configuration(&server.uri(), false); + config["upstream_callbacks"].as_array_mut().unwrap().insert(0, json!({ + "method":"host.http.dispatch", "params": {"settings":null,"method":"GET", "uri":"/api/admin/settings", "version":"HTTP/1.1", "headers":[], "timeout_ms":null, "body":{"kind":"empty"}} + })); + let (_cache, _, runtime) = setup(config).await; + let calls = Arc::new(AtomicUsize::new(0)); + let dispatcher: Arc = + Arc::new(Http(calls.clone())); + runtime.bind_http(&dispatcher).unwrap(); + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&generation, Account::new(), "key-one", None); + while let Some(event) = stream.next().await { + event.unwrap(); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + drop(stream); + drop(generation); + drop(dispatcher); + runtime.shutdown().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs index ae1796f16..876743e06 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -2,7 +2,7 @@ use gateway_admin::{ model::plugins::PluginHostCompatibility, ports::plugins::PluginPackageInspector as _, }; use gateway_plugin_runtime::{PackageInspector, PackageLimits}; -use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; use sha2::{Digest as _, Sha256}; #[test] @@ -32,30 +32,23 @@ fn host_compatibility_only_advertises_known_contracts() { ); } } - for identifier in &compatibility.permissions { - let permission: Permission = serde_json::from_value(identifier.clone().into()) - .expect("host permission is understood by the SDK"); - assert_eq!(identifier, permission.as_str()); - } } #[test] -fn host_compatibility_preserves_both_middleware_contracts() { +fn host_compatibility_requires_the_trusted_middleware_contract() { let compatibility: PluginHostCompatibility = serde_json::from_str(include_str!("../../plugin-host-compatibility.json")) .expect("compatibility JSON"); - assert!(compatibility.supports_capability("middleware", 1)); - assert!(compatibility.supports_capability("middleware", 2)); - assert!(!compatibility.supports_capability("middleware", 3)); + assert!(!compatibility.supports_capability("middleware", 1)); + assert!(!compatibility.supports_capability("middleware", 2)); + assert!(compatibility.supports_capability("middleware", 3)); assert!(!compatibility.supports_capability("openai", 1)); - assert!(!compatibility.supports_permission("xai")); } #[tokio::test] async fn package_inspector_returns_static_requirements_without_starting_the_plugin() { let archive = crate::support::package_with_contributions( b"not-an-executable", - vec![], Contributions::from([crate::support::contribution( Capability::Middleware, vec![Stage::Request], @@ -79,6 +72,5 @@ async fn package_inspector_returns_static_requirements_without_starting_the_plug requirements.protocol_version, gateway_plugin_sdk::PROTOCOL_VERSION ); - assert_eq!(requirements.capabilities, vec![("middleware".into(), 1)]); - assert!(requirements.permissions.is_empty()); + assert_eq!(requirements.capabilities, vec![("middleware".into(), 3)]); } diff --git a/backend/crates/gateway-plugin/runtime/tests/package/inspection.rs b/backend/crates/gateway-plugin/runtime/tests/package/inspection.rs deleted file mode 100644 index 52ab31669..000000000 --- a/backend/crates/gateway-plugin/runtime/tests/package/inspection.rs +++ /dev/null @@ -1,18 +0,0 @@ -use gateway_admin::ports::plugins::PluginPackageInspector; -use gateway_plugin_runtime::{PackageInspector, PackageLimits}; -use gateway_plugin_sdk::Permission; - -#[test] -fn permission_presentations_come_from_the_current_sdk_without_an_archive() { - let inspector = PackageInspector::new(PackageLimits::default(), "3.14.0".parse().unwrap()); - let permissions = vec!["models".to_owned(), "future_permission".to_owned()]; - let descriptions = inspector.permission_descriptions(&permissions); - assert_eq!(descriptions[0].permission, "models"); - assert_eq!(descriptions[0].label, Permission::Models.label()); - assert_eq!( - descriptions[0].description, - Permission::Models.description() - ); - assert_eq!(descriptions[1].label, "future_permission"); - assert!(descriptions[1].description.is_empty()); -} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/mod.rs b/backend/crates/gateway-plugin/runtime/tests/package/mod.rs index aabaed817..e44fc96d4 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/mod.rs @@ -1,4 +1,3 @@ mod cache; mod compatibility; -mod inspection; mod validation; diff --git a/backend/crates/gateway-plugin/runtime/tests/package/validation.rs b/backend/crates/gateway-plugin/runtime/tests/package/validation.rs index 600bbb634..c2f6537b6 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/validation.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/validation.rs @@ -59,7 +59,7 @@ fn package_with_icon(path: &str, content_type: &str, icon: Vec) -> std::sync ]); let resources = BTreeMap::from([(path.to_owned(), content_type.to_owned())]); let manifest = serde_json::json!({ - "manifestVersion": 1, + "manifestVersion":2, "name": "icon", "displayName": "Icon", "publisher": "test", @@ -73,7 +73,7 @@ fn package_with_icon(path: &str, content_type: &str, icon: Vec) -> std::sync "resources": resources, "icon": path, "package": { - "protocolVersion": 1, + "protocolVersion":2, "target": { "os": std::env::consts::OS, "architecture": std::env::consts::ARCH @@ -278,7 +278,7 @@ fn icon_content_must_fully_decode_match_its_type_and_fit_dimensions() { #[test] fn obsolete_manifest_fields_are_rejected_without_aliases() { let manifest = serde_json::json!({ - "manifestVersion": 1, + "manifestVersion":2, "name": "example", "displayName": "Example", "publisher": "test", @@ -303,7 +303,7 @@ fn obsolete_manifest_fields_are_rejected_without_aliases() { #[test] fn source_manifest_cannot_be_installed_without_package_metadata() { let manifest = serde_json::json!({ - "manifestVersion": 1, + "manifestVersion":2, "name": "example", "displayName": "Example", "publisher": "test", @@ -330,7 +330,7 @@ fn source_manifest_cannot_be_installed_without_package_metadata() { #[test] fn duplicate_contribution_keys_are_rejected_before_map_overwrite() { let manifest = br#"{ - "manifestVersion":1, + "manifestVersion":2, "name":"example", "displayName":"Example", "publisher":"test", @@ -341,8 +341,8 @@ fn duplicate_contribution_keys_are_rejected_before_map_overwrite() { "main":"bin/worker", "runtime":"trustedProcess", "contributes":{ - "middleware":{"id":"test.example.first","version":1,"stages":["request"]}, - "middleware":{"id":"test.example.second","version":1,"stages":["request"]} + "middleware":{"id":"test.example.first","version":3,"stages":["request"]}, + "middleware":{"id":"test.example.second","version":3,"stages":["request"]} } }"#; let archive = crate::support::archive(BTreeMap::from([( diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs index 0f98f2487..5ac43fdd0 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs @@ -10,20 +10,9 @@ use futures::future::BoxFuture; use gateway_plugin_runtime::{ CallbackHandler, PackageLimits, RpcError, RpcLimits, RpcReply, RpcSession, ValidatedPackage, }; -use gateway_plugin_sdk::{CallContext, ErrorCode, Handshake, Permission, PluginFault, Stage}; +use gateway_plugin_sdk::{CallContext, Handshake, PluginFault, Stage}; use serde_json::json; -const CALLBACK_PERMISSIONS: [(&str, Permission); 8] = [ - ("host.http.do", Permission::Network), - ("host.model.execute", Permission::Models), - ("host.auth.list", Permission::Accounts), - ("host.auth.get", Permission::Accounts), - ("host.auth.save", Permission::Accounts), - ("host.affinity.lookup", Permission::Requests), - ("host.models.list", Permission::Models), - ("host.keys.list", Permission::Models), -]; - #[derive(Default)] struct Callbacks { called: AtomicUsize, @@ -48,17 +37,10 @@ impl CallbackHandler for Callbacks { } async fn session(callbacks: Arc) -> (tempfile::TempDir, Arc) { - session_with_permissions(callbacks, Permission::ALL.to_vec()).await -} - -async fn session_with_permissions( - callbacks: Arc, - permissions: Vec, -) -> (tempfile::TempDir, Arc) { let cache = tempfile::tempdir().unwrap(); let package = Arc::new( ValidatedPackage::read( - crate::support::package_with_permissions(crate::support::worker(), permissions.clone()), + crate::support::package(crate::support::worker()), None, PackageLimits::default(), ) @@ -78,7 +60,6 @@ async fn session_with_permissions( incarnation: uuid::Uuid::new_v4().to_string(), configuration: json!({}), contributes: gateway_plugin_sdk::Contributions::new(), - permissions, }; let processes = gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(128).unwrap()); @@ -109,47 +90,32 @@ async fn invoke_callback( .await } -fn assert_permission_denied(reply: Result) { - assert!(matches!( - reply, - Err(RpcError::Remote(PluginFault { - code: ErrorCode::PermissionDenied, - .. - })) - )); -} - #[tokio::test] -async fn public_management_cannot_inherit_any_granted_host_callback_permission() { +async fn callbacks_are_dispatched_in_every_stage_without_permission_declarations() { let callbacks = Arc::new(Callbacks::default()); let (_cache, session) = session(Arc::clone(&callbacks)).await; - for (method, _) in CALLBACK_PERMISSIONS { - assert_permission_denied(invoke_callback(&session, Stage::PublicManagement, method).await); - } - for method in ["host.log", "host.state.get"] { - assert_permission_denied(invoke_callback(&session, Stage::PublicManagement, method).await); - } - assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn registration_and_configuration_reject_network_and_model_callbacks() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(Arc::clone(&callbacks)).await; - - for stage in [Stage::Registration, Stage::Configuration] { - assert_permission_denied(invoke_callback(&session, stage, "host.http.do").await); - assert_permission_denied(invoke_callback(&session, stage, "host.model.execute").await); - } - assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); - - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn upstream_connections_require_their_own_grant_and_exact_execution_stage() { let methods = [ + "host.http.do", + "host.model.execute", + "host.auth.list", + "host.auth.get", + "host.auth.save", + "host.affinity.lookup", + "host.models.list", + "host.keys.list", + "host.log", + "host.state.get", + "host.state.put", + "host.data.accounts.list", + "host.data.keys.get", + "host.data.quota.get", + "host.groups.ensure", + "host.groups.change_members", + "host.keys.ensure", + "host.keys.reset_budget", + "host.keys.get_budget", + "host.keys.update_budget_limits", + "host.quota_observations.refresh", "host.upstream.http.do", "host.upstream.http.do_stream", "host.upstream.http.stream_read", @@ -159,345 +125,33 @@ async fn upstream_connections_require_their_own_grant_and_exact_execution_stage( "host.upstream.websocket.read", "host.upstream.websocket.close", ]; - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session_with_permissions( - Arc::clone(&callbacks), - vec![Permission::UpstreamConnections], - ) - .await; - for method in methods { - assert!( - invoke_callback(&session, Stage::Upstream, method) - .await - .is_ok() - ); - for stage in [ - Stage::Request, - Stage::Attempt, - Stage::Management, - Stage::Observation, - Stage::Registration, - ] { - assert_permission_denied(invoke_callback(&session, stage, method).await); - } - } - session.shutdown(Duration::from_secs(1)).await; - let (_cache, session) = - session_with_permissions(callbacks, vec![Permission::Network, Permission::Accounts]).await; - for method in methods { - assert_permission_denied(invoke_callback(&session, Stage::Upstream, method).await); - } - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn active_stages_use_resource_domains_without_operation_whitelists() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(Arc::clone(&callbacks)).await; - - for stage in [ - Stage::Routing, - Stage::Scheduling, - Stage::Request, - Stage::Attempt, - Stage::Observation, - Stage::Authentication, - Stage::Management, - Stage::CommandLine, - ] { - for (method, _) in CALLBACK_PERMISSIONS { - assert!( - invoke_callback(&session, stage, method).await.is_ok(), - "{method} domain should be available during {stage:?}" - ); - } - } - assert_eq!( - callbacks.called.load(Ordering::Relaxed), - 8 * CALLBACK_PERMISSIONS.len() - ); - - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn missing_domains_are_denied_before_dispatch() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session_with_permissions(Arc::clone(&callbacks), vec![]).await; - - for (method, _) in CALLBACK_PERMISSIONS { - assert_permission_denied(invoke_callback(&session, Stage::Observation, method).await); - } - assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); - - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn logs_and_own_state_are_infrastructure_without_permissions() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session_with_permissions(Arc::clone(&callbacks), vec![]).await; - - for stage in [ + let stages = [ Stage::Registration, Stage::Configuration, + Stage::Authentication, + Stage::Routing, + Stage::Scheduling, + Stage::Retry, + Stage::Request, + Stage::Attempt, + Stage::Upstream, Stage::Observation, Stage::Management, - Stage::Authentication, - ] { - assert!(invoke_callback(&session, stage, "host.log").await.is_ok()); - if matches!(stage, Stage::Registration | Stage::Configuration) { - assert_permission_denied(invoke_callback(&session, stage, "host.state.get").await); - } else { + Stage::CommandLine, + Stage::PublicManagement, + Stage::Maintenance, + ]; + for stage in stages { + for method in methods { assert!( - invoke_callback(&session, stage, "host.state.get") - .await - .is_ok() + invoke_callback(&session, stage, method).await.is_ok(), + "{stage:?}: {method}" ); } } - assert_eq!(callbacks.called.load(Ordering::Relaxed), 8); - - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn execution_allows_each_declared_callback_permission() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(Arc::clone(&callbacks)).await; - - for (method, _) in CALLBACK_PERMISSIONS { - let reply = invoke_callback(&session, Stage::Request, method).await; - assert!(reply.is_ok(), "{method} should be allowed during execution"); - } assert_eq!( callbacks.called.load(Ordering::Relaxed), - CALLBACK_PERMISSIONS.len() + methods.len() * stages.len() ); - session.shutdown(Duration::from_secs(1)).await; } - -#[tokio::test] -async fn fact_callbacks_require_independent_grants_and_control_plane_stages() { - for permissions in [ - vec![], - vec![Permission::Accounts], - vec![Permission::Keys], - vec![Permission::Models], - vec![Permission::KeyBudgets], - vec![Permission::QuotaObservations], - vec![Permission::Data], - ] { - let authorized = permissions.contains(&Permission::Data); - let quota_authorized = permissions.contains(&Permission::QuotaObservations); - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session_with_permissions(Arc::clone(&callbacks), permissions).await; - for stage in [ - Stage::Registration, - Stage::Configuration, - Stage::Routing, - Stage::Scheduling, - Stage::Request, - Stage::Attempt, - Stage::Retry, - Stage::Observation, - Stage::Authentication, - Stage::PublicManagement, - Stage::Management, - Stage::CommandLine, - Stage::Maintenance, - ] { - for method in [ - "host.data.accounts.list", - "host.data.keys.get", - "host.data.quota.get", - ] { - let reply = invoke_callback(&session, stage, method).await; - if (authorized || (quota_authorized && method == "host.data.quota.get")) - && matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) - { - assert!(reply.is_ok()); - } else { - assert_permission_denied(reply); - } - } - } - assert_eq!( - callbacks.called.load(Ordering::Relaxed), - if authorized { - 9 - } else if quota_authorized { - 3 - } else { - 0 - } - ); - session.shutdown(Duration::from_secs(1)).await; - } -} - -#[tokio::test] -async fn retry_stage_allows_only_logs_and_private_state() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(Arc::clone(&callbacks)).await; - for (method, _) in CALLBACK_PERMISSIONS { - assert_permission_denied(invoke_callback(&session, Stage::Retry, method).await); - } - for method in ["host.log", "host.state.get"] { - assert!( - invoke_callback(&session, Stage::Retry, method) - .await - .is_ok() - ); - } - assert_eq!(callbacks.called.load(Ordering::Relaxed), 2); - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn managed_resources_require_their_domains_and_control_plane_stages() { - for (method, permission) in [ - ("host.groups.ensure", Permission::Groups), - ("host.groups.change_members", Permission::Groups), - ("host.keys.ensure", Permission::Keys), - ("host.keys.reset_budget", Permission::KeyBudgets), - ("host.keys.get_budget", Permission::KeyBudgets), - ("host.keys.update_budget_limits", Permission::KeyBudgets), - ( - "host.quota_observations.refresh", - Permission::QuotaObservations, - ), - ] { - for granted in [false, true] { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session_with_permissions( - callbacks.clone(), - if granted { vec![permission] } else { vec![] }, - ) - .await; - for stage in [ - Stage::Registration, - Stage::Configuration, - Stage::PublicManagement, - Stage::Request, - Stage::Observation, - Stage::Management, - Stage::CommandLine, - Stage::Maintenance, - ] { - let reply = invoke_callback(&session, stage, method).await; - if granted - && matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ) - { - assert!(reply.is_ok()); - } else { - assert_permission_denied(reply); - } - } - session.shutdown(Duration::from_secs(1)).await; - } - } -} - -#[tokio::test] -async fn maintenance_has_data_and_state_access_but_cannot_execute_models_or_read_credentials() { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(callbacks).await; - for method in [ - "host.data.accounts.list", - "host.data.keys.get", - "host.data.quota.get", - "host.keys.list", - "host.keys.reset_budget", - "host.state.put", - "host.log", - ] { - assert!( - invoke_callback(&session, Stage::Maintenance, method) - .await - .is_ok() - ); - } - for method in [ - "host.auth.get", - "host.auth.save", - "host.http.do", - "host.model.execute", - ] { - assert_permission_denied(invoke_callback(&session, Stage::Maintenance, method).await); - } - session.shutdown(Duration::from_secs(1)).await; -} - -#[tokio::test] -async fn budget_access_does_not_grant_models_or_key_provisioning_and_models_do_not_grant_reset() { - for permission in [Permission::KeyBudgets, Permission::Models, Permission::Keys] { - let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session_with_permissions(callbacks, vec![permission]).await; - for stage in [ - Stage::Management, - Stage::CommandLine, - Stage::Maintenance, - Stage::Request, - Stage::Attempt, - Stage::Observation, - Stage::Routing, - Stage::Scheduling, - Stage::Authentication, - Stage::Retry, - Stage::Registration, - Stage::Configuration, - Stage::PublicManagement, - ] { - let reply = invoke_callback(&session, stage, "host.keys.list").await; - let allowed = match permission { - Permission::KeyBudgets => matches!( - stage, - Stage::Management | Stage::CommandLine | Stage::Maintenance - ), - Permission::Models => matches!( - stage, - Stage::Management - | Stage::CommandLine - | Stage::Request - | Stage::Attempt - | Stage::Observation - | Stage::Routing - | Stage::Scheduling - | Stage::Authentication - ), - _ => false, - }; - if allowed { - assert!(reply.is_ok()); - } else { - assert_permission_denied(reply); - } - if permission != Permission::KeyBudgets { - assert_permission_denied( - invoke_callback(&session, stage, "host.keys.reset_budget").await, - ); - } - } - if permission == Permission::KeyBudgets { - for method in [ - "host.model.execute", - "host.models.list", - "host.keys.ensure", - "host.auth.get", - ] { - assert_permission_denied( - invoke_callback(&session, Stage::Management, method).await, - ); - } - } - session.shutdown(Duration::from_secs(1)).await; - } -} diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs index d94b44907..75ee1a397 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs @@ -10,7 +10,7 @@ use futures::future::BoxFuture; use gateway_plugin_runtime::{ CallbackHandler, PackageLimits, RpcError, RpcLimits, RpcReply, RpcSession, ValidatedPackage, }; -use gateway_plugin_sdk::{CallContext, ErrorCode, Handshake, Permission, PluginFault, Stage}; +use gateway_plugin_sdk::{CallContext, ErrorCode, Handshake, PluginFault, Stage}; use serde_json::json; use tokio::sync::{Barrier, Notify}; @@ -174,14 +174,11 @@ impl CallbackHandler for Callbacks { } } -async fn session( - permissions: Vec, - callbacks: Arc, -) -> (tempfile::TempDir, Arc) +async fn session(callbacks: Arc) -> (tempfile::TempDir, Arc) where C: CallbackHandler + 'static, { - let (cache, prepared, handshake, processes) = prepared_session(permissions); + let (cache, prepared, handshake, processes) = prepared_session(); let session = RpcSession::start( prepared, handshake, @@ -194,9 +191,7 @@ where (cache, Arc::new(session)) } -fn prepared_session( - permissions: Vec, -) -> ( +fn prepared_session() -> ( tempfile::TempDir, Arc, Handshake, @@ -205,7 +200,7 @@ fn prepared_session( let cache = tempfile::tempdir().unwrap(); let package = Arc::new( ValidatedPackage::read( - crate::support::package_with_permissions(crate::support::worker(), permissions.clone()), + crate::support::package(crate::support::worker()), None, PackageLimits::default(), ) @@ -225,7 +220,6 @@ fn prepared_session( incarnation: uuid::Uuid::new_v4().to_string(), configuration: json!({}), contributes: gateway_plugin_sdk::Contributions::new(), - permissions, }; let processes = gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(128).unwrap()); @@ -235,7 +229,7 @@ fn prepared_session( #[cfg(target_os = "linux")] #[tokio::test] async fn newly_unpacked_busy_executable_recovers_within_startup_deadline() { - let (_cache, prepared, handshake, processes) = prepared_session(vec![]); + let (_cache, prepared, handshake, processes) = prepared_session(); let writer = std::fs::OpenOptions::new() .write(true) .open(prepared.executable()) @@ -260,7 +254,7 @@ async fn newly_unpacked_busy_executable_recovers_within_startup_deadline() { #[cfg(target_os = "linux")] #[tokio::test] async fn persistently_busy_executable_stops_retrying_and_releases_capacity() { - let (_cache, prepared, handshake, _) = prepared_session(vec![]); + let (_cache, prepared, handshake, _) = prepared_session(); let processes = gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(1).unwrap()); let writer = std::fs::OpenOptions::new() @@ -308,7 +302,7 @@ async fn persistently_busy_executable_stops_retrying_and_releases_capacity() { #[tokio::test] async fn shutdown_returns_after_in_flight_callback_future_is_dropped() { let callbacks = Arc::new(BlockingCallbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let callback_started = callbacks.started.notified(); let calling = { let session = Arc::clone(&session); @@ -339,7 +333,7 @@ async fn shutdown_returns_after_in_flight_callback_future_is_dropped() { #[tokio::test] async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() { let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; assert_eq!( session .context(Stage::Registration, Duration::MAX) @@ -365,7 +359,7 @@ async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() { #[tokio::test] async fn slow_management_call_does_not_block_an_independent_call() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let context = session.context(Stage::Management, Duration::from_secs(2)); let slow_session = Arc::clone(&session); let slow = @@ -386,7 +380,7 @@ async fn slow_management_call_does_not_block_an_independent_call() { #[tokio::test] async fn large_binary_payload_round_trips_through_calls_and_callbacks() { let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let payload = (0..4 * 1024 * 1024 + 17) .map(|index| (index % 251) as u8) .collect::>(); @@ -410,7 +404,7 @@ 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() { let callbacks = Arc::new(LifecycleCallbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + 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); @@ -455,7 +449,7 @@ async fn oversized_call_frames_do_not_close_the_session_or_begin_callbacks() { #[tokio::test] async fn oversized_callback_metadata_only_fails_its_parent_call() { - let (_cache, session) = session(vec![], Arc::new(OversizedReplyCallbacks)).await; + let (_cache, session) = session(Arc::new(OversizedReplyCallbacks)).await; for error in [false, true] { assert!(matches!( session @@ -487,7 +481,7 @@ async fn oversized_callback_metadata_only_fails_its_parent_call() { async fn concurrent_calls_keep_their_wire_ids_monotonic() { let callbacks = Arc::new(GatedFirstBegin::default()); let entered = callbacks.entered.notified(); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let ready = Arc::new(Barrier::new(17)); let mut calls = Vec::with_capacity(16); for index in 0..16 { @@ -533,7 +527,7 @@ async fn concurrent_calls_keep_their_wire_ids_monotonic() { async fn cancelling_a_call_waiting_for_send_order_does_not_block_following_calls() { let callbacks = Arc::new(GatedFirstBegin::default()); let entered = callbacks.entered.notified(); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let first = { let session = Arc::clone(&session); tokio::spawn(async move { @@ -590,7 +584,7 @@ async fn cancelling_a_call_waiting_for_send_order_does_not_block_following_calls async fn waiting_for_send_order_consumes_the_original_call_deadline() { let callbacks = Arc::new(GatedFirstBegin::default()); let entered = callbacks.entered.notified(); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let first = { let session = Arc::clone(&session); tokio::spawn(async move { @@ -633,9 +627,9 @@ async fn waiting_for_send_order_consumes_the_original_call_deadline() { } #[tokio::test] -async fn undeclared_callback_permission_never_reaches_the_host_handler() { +async fn callback_reaches_host_without_permission_declarations() { let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let reply = session .call( "callback_method", @@ -644,21 +638,15 @@ async fn undeclared_callback_permission_never_reaches_the_host_handler() { vec![], ) .await; - assert!(matches!( - reply, - Err(RpcError::Remote(PluginFault { - code: ErrorCode::PermissionDenied, - .. - })) - )); - assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); + assert!(reply.is_ok()); + assert_eq!(callbacks.called.load(Ordering::Relaxed), 1); session.shutdown(Duration::from_secs(1)).await; } #[tokio::test] -async fn unknown_parent_cannot_borrow_another_calls_permissions() { +async fn unknown_parent_cannot_borrow_another_calls_resources() { let callbacks = Arc::new(Callbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let reply = session .call( "forged_callback", @@ -673,7 +661,7 @@ async fn unknown_parent_cannot_borrow_another_calls_permissions() { #[tokio::test] async fn explicit_policy_denial_keeps_its_error_kind_and_status() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let reply = session .call( "deny", @@ -695,7 +683,7 @@ async fn explicit_policy_denial_keeps_its_error_kind_and_status() { #[tokio::test] async fn ignored_cancellation_closes_the_incarnation_and_settles_other_calls() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let first = session.call( "hang_uncancellable", session.context(Stage::Request, Duration::from_millis(100)), @@ -716,7 +704,7 @@ async fn ignored_cancellation_closes_the_incarnation_and_settles_other_calls() { #[tokio::test] async fn child_crash_fails_the_call_without_waiting_for_its_deadline() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let call = session.call( "crash", session.context(Stage::Request, Duration::from_secs(30)), @@ -733,7 +721,7 @@ async fn child_crash_fails_the_call_without_waiting_for_its_deadline() { async fn malformed_worker_frames_fail_boundedly_and_release_call_resources() { for method in ["malformed_truncated_frame", "malformed_frame_length"] { let callbacks = Arc::new(LifecycleCallbacks::default()); - let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let (_cache, session) = session(Arc::clone(&callbacks)).await; let result = tokio::time::timeout( Duration::from_secs(2), session.call( @@ -755,7 +743,7 @@ async fn malformed_worker_frames_fail_boundedly_and_release_call_resources() { #[tokio::test] async fn slow_stream_consumer_backpressures_the_child_without_blocking_control_calls() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let mut stream = session .call_stream( "stream", @@ -790,7 +778,7 @@ async fn slow_stream_consumer_backpressures_the_child_without_blocking_control_c #[tokio::test] async fn stream_window_and_sequence_violations_fail_closed() { for method in ["stream_overflow", "stream_sequence"] { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let mut stream = session .call_stream( method, @@ -807,7 +795,7 @@ async fn stream_window_and_sequence_violations_fail_closed() { #[tokio::test] async fn dropping_a_backpressured_stream_cancels_only_that_call() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let stream = session .call_stream( "stream", @@ -834,7 +822,7 @@ async fn dropping_a_backpressured_stream_cancels_only_that_call() { #[tokio::test] async fn acknowledged_timeout_does_not_interrupt_another_call() { - let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let (_cache, session) = session(Arc::new(Callbacks::default())).await; let first = session.call( "hang", session.context(Stage::Management, Duration::from_millis(100)), diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index 83f086a76..db51ee314 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -14,7 +14,7 @@ use gateway_admin::{ }, instances::{ PluginCapabilityBinding, PluginFailurePolicy, PluginFrontendIdentityBinding, - PluginInstance, PluginPermissionGrant, + PluginInstance, }, }, pricing::PricingSyncPreview, @@ -165,26 +165,17 @@ impl Environment { pub async fn plugin( &self, configuration: serde_json::Value, - grants: Vec, ) -> (Arc, gateway_core::CoreBundle) { - self.install_plugin(configuration, grants).await; + self.install_plugin(configuration).await; self.runtime().await } - pub async fn install_plugin( - &self, - configuration: serde_json::Value, - grants: Vec, - ) { + pub async fn install_plugin(&self, configuration: serde_json::Value) { let plugin_id = configuration .get("plugin_id") .and_then(serde_json::Value::as_str) .map(str::to_owned) .unwrap_or_else(|| super::DEFAULT_PLUGIN_ID.to_owned()); - let permissions = grants - .iter() - .map(|grant| serde_json::from_value(json!(grant.permission)).unwrap()) - .collect(); let mut contributes = Contributions::new(); if configuration.get("maintenance_fixture").is_some() { contributes.extend([super::contribution_for_id( @@ -224,14 +215,35 @@ impl Environment { vec!["openai".into()], )]); } - let has_middleware = configuration.get("middleware_marker").is_some(); + let has_service = configuration["service"] == true; + let has_http = configuration["http"] == true; + let has_middleware = + configuration.get("middleware_marker").is_some() || has_service || has_http; if has_middleware { contributes.extend([super::contribution_for_id( &plugin_id, Capability::Middleware, - vec![Stage::Request], - vec!["openai".into()], - vec!["openai".into()], + vec![if has_service { + Stage::Service + } else if has_http { + Stage::Http + } else { + Stage::Request + }], + vec![if has_service { + "service".into() + } else if has_http { + "http".into() + } else { + "openai".into() + }], + vec![if has_service { + "service".into() + } else if has_http { + "http".into() + } else { + "openai".into() + }], )]); } let frontend_authentication = configuration @@ -285,12 +297,10 @@ impl Environment { vec![], )]); } - let archive = super::package_with_contributions_for_id( - super::worker(), - &plugin_id, - permissions, - contributes, - ); + 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()); + let archive = super::package_with_contributions_for_id(worker, &plugin_id, contributes); let inspector = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()); let artifact = inspector .inspect(Arc::clone(&archive), None) @@ -314,14 +324,6 @@ impl Environment { .accept_artifact(&installed.artifact.metadata.sha256, &mutation) .await .unwrap(); - let grants = installed - .artifact - .metadata - .requested_permissions - .iter() - .cloned() - .map(|permission| PluginPermissionGrant { permission }) - .collect(); let instance = PluginInstance { id: uuid::Uuid::new_v4().to_string(), name: "provider".into(), @@ -330,7 +332,7 @@ impl Environment { trusted_process: true, configuration, secrets: BTreeMap::new(), - grants, + bindings: [ has_upstream_adapter.then(|| PluginCapabilityBinding { contribution: format!("{plugin_id}.upstreamAdapter"), @@ -341,6 +343,7 @@ impl Environment { account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![], }), has_model_router.then(|| PluginCapabilityBinding { @@ -352,6 +355,7 @@ impl Environment { account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![], }), has_scheduler.then(|| PluginCapabilityBinding { @@ -363,6 +367,7 @@ impl Environment { account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![], }), frontend_authentication @@ -380,6 +385,7 @@ impl Environment { account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![PluginFrontendIdentityBinding { principal: principal.clone(), client_key_id: key_id.clone(), @@ -387,13 +393,20 @@ impl Environment { }), has_middleware.then(|| PluginCapabilityBinding { contribution: format!("{plugin_id}.middleware"), - stage: "request".into(), + stage: if has_service { + "service".into() + } else if has_http { + "http".into() + } else { + "request".into() + }, order: 0, failure_policy: PluginFailurePolicy::Reject, client_key_ids: vec![], account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![], }), ] @@ -409,7 +422,14 @@ impl Environment { } pub async fn runtime(&self) -> (Arc, gateway_core::CoreBundle) { - let runtime = self.plugin_runtime(); + self.runtime_with_limits(RpcLimits::default()).await + } + + pub async fn runtime_with_limits( + &self, + limits: RpcLimits, + ) -> (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(), @@ -464,6 +484,10 @@ impl Environment { } fn plugin_runtime(&self) -> Arc { + self.plugin_runtime_with_limits(RpcLimits::default()) + } + + fn plugin_runtime_with_limits(&self, rpc_limits: RpcLimits) -> Arc { Arc::new( PluginRuntime::new( self.store.admin_ports().plugins(), @@ -472,7 +496,7 @@ impl Environment { cache_directory: self.directory.path().join("cache"), host_version: "1.0.0".parse().unwrap(), package_limits: PackageLimits::default(), - rpc_limits: RpcLimits::default(), + rpc_limits, restart_circuit: Default::default(), }, Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), @@ -480,14 +504,7 @@ impl Environment { std::num::NonZeroUsize::new(128).unwrap(), )), ) - .with_oauth_pending(self.store.provider_ports().oauth_pending()) - .with_network_policy( - gateway_host::outbound::NetworkPolicy::new(&[ - "127.0.0.0/8".into(), - "::1/128".into(), - ]) - .unwrap(), - ), + .with_oauth_pending(self.store.provider_ports().oauth_pending()), ) } @@ -512,6 +529,14 @@ impl Environment { ClientConfig::default(), self.store.admin_ports(), gateway_admin::AdminRuntimePorts { + service_middleware: { + let snapshots = core.snapshots(); + let middleware = runtime.middleware_registry(); + std::sync::Arc::new(move || { + let snapshot = snapshots.snapshot_for_diagnostics()?; + middleware.resolve(snapshot.extensions()?) + }) + }, plugin_preparation: runtime.clone(), plugin_management: runtime.clone(), published_snapshot: core.snapshots(), @@ -842,9 +867,3 @@ pub fn mutation() -> MutationContext { request_id: "plugin-host-account-test".into(), } } - -pub fn account_grant(permission: &str) -> PluginPermissionGrant { - PluginPermissionGrant { - permission: permission.into(), - } -} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs deleted file mode 100644 index 1879c0dfc..000000000 --- a/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs +++ /dev/null @@ -1,81 +0,0 @@ -use gateway_plugin_sdk::{ - Capability, ContributionDeclaration, Contributions, Stage, - client::{MiddlewareCall, MiddlewarePlugin, PluginSession, SessionConfig}, -}; - -#[tokio::main(flavor = "current_thread")] -async fn main() -> Result<(), Box> { - let session = PluginSession::accept( - tokio::io::stdin(), - tokio::io::stdout(), - SessionConfig::default(), - ) - .await?; - let mode = session.handshake().configuration["mode"] - .as_str() - .unwrap_or("map") - .to_owned(); - let configuration = session.handshake().configuration.clone(); - let plugin = MiddlewarePlugin::new( - &Contributions::from([( - Capability::Middleware, - ContributionDeclaration { - id: "test.example.middleware".into(), - version: configuration["middleware_version"] - .as_u64() - .unwrap_or(1) - .try_into()?, - stages: vec![if configuration["attempt"] == true { - Stage::Attempt - } else { - Stage::Request - }], - input_formats: vec!["openai".into()], - output_formats: vec!["openai".into()], - }, - )]), - move |mut call: MiddlewareCall| { - let mode = mode.clone(); - let configuration = configuration.clone(); - async move { - if mode == "declare" { - call.request - .replace_body(serde_json::to_vec(&configuration["body"]).unwrap()); - call.request.declare_capabilities( - serde_json::from_value(configuration["capabilities"].clone()).unwrap(), - ); - } - if mode == "headers" { - call.request - .append_header("x-sdk-request", b"active".to_vec()); - } - if mode == "inspect" && !call.request.body.is_empty() { - // 读取和解析只能作用于副本,不能把解析结果写回未改写的正文。 - assert!( - serde_json::from_slice::(&call.request.body).is_ok() - ); - } - let mut response = call.next.run(call.request).await?; - match mode.as_str() { - "passthrough" | "declare" => {} - "inspect" => { - response.body = response.body.inspect_frames(|frame| { - assert!(!frame.payload.is_empty()); - })?; - } - "headers" => response.append_header("x-sdk-response", b"active".to_vec()), - _ => { - response.append_header("x-sdk-plugin", b"active".to_vec()); - response.body = response.body.map_frames(|mut frame| { - frame.payload.push(b' '); - Ok(vec![frame]) - })?; - } - } - Ok(response) - } - }, - )?; - session.run(plugin).await?; - Ok(()) -} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware/http_resources.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware/http_resources.rs new file mode 100644 index 000000000..0c5933cd8 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/middleware/http_resources.rs @@ -0,0 +1,90 @@ +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::middleware::http as wire, + client::{HostClient, HostReply, SessionError}, +}; +use serde_json::{Value, json}; + +async fn call(host: &HostClient, method: &str, params: Value) -> Result { + host.call(method, params, Vec::new()) + .await + .map_err(SessionError::into_plugin_fault) +} + +async fn create(host: &HostClient) -> Result { + let reply = call(host, wire::BODY_CREATE_METHOD, json!({})).await?; + let pipe: wire::BodyPipe = serde_json::from_value(reply.result).unwrap(); + Ok(pipe.handle) +} + +pub async fn check_upload_lifecycle(host: &HostClient) -> Result<(), PluginFault> { + let mut handles = Vec::new(); + for _ in 0..16 { + handles.push(create(host).await?); + } + assert_eq!(create(host).await.unwrap_err().code, ErrorCode::Capacity); + + // 关闭立即归还名额;重复关闭不能误伤后续分配。 + let closed = handles.remove(0); + for _ in 0..2 { + call(host, wire::BODY_CLOSE_METHOD, json!({"handle":closed})).await?; + } + handles.push(create(host).await?); + assert_eq!(create(host).await.unwrap_err().code, ErrorCode::Capacity); + + // 写完后读端仍能取得 EOF,但不再占用活动上传名额。 + let finished = handles.remove(0); + call( + host, + wire::BODY_WRITE_METHOD, + json!({"handle":finished,"eof":true,"trailers":null}), + ) + .await?; + handles.push(create(host).await?); + let eof = call( + host, + wire::BODY_READ_METHOD, + json!({"handle":finished,"maximum_bytes":1}), + ) + .await?; + assert_eq!(eof.result["eof"], true); + + // 正文已转交给子请求,关闭返回正文必须唤醒被单帧背压阻塞的上传。 + let active = handles.remove(0); + let reply = call( + host, + wire::DISPATCH_METHOD, + json!({ + "settings":null,"method":"POST","uri":"/child","version":"HTTP/1.1", + "headers":[],"timeout_ms":null,"body":{"kind":"handle","handle":active}, + }), + ) + .await?; + let response: wire::Response = serde_json::from_value(reply.result).unwrap(); + let wire::Body::Handle { handle } = response.body else { + panic!("response handle expected") + }; + let write = json!({"handle":active,"eof":false,"trailers":null}); + host.call(wire::BODY_WRITE_METHOD, write.clone(), vec![1]) + .await + .map_err(SessionError::into_plugin_fault)?; + let blocked = host.call(wire::BODY_WRITE_METHOD, write, vec![2]); + tokio::pin!(blocked); + tokio::select! { + _ = &mut blocked => panic!("upload should wait for the reader"), + () = tokio::time::sleep(std::time::Duration::from_millis(20)) => {}, + } + call(host, wire::BODY_CLOSE_METHOD, json!({"handle":handle})).await?; + let error = blocked + .await + .err() + .expect("closed reader must fail the write") + .into_plugin_fault(); + assert_eq!(error.code, ErrorCode::Conflict); + handles.push(create(host).await?); + assert_eq!(create(host).await.unwrap_err().code, ErrorCode::Capacity); + for handle in handles { + call(host, wire::BODY_CLOSE_METHOD, json!({"handle":handle})).await?; + } + Ok(()) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs new file mode 100644 index 000000000..65857bc4e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs @@ -0,0 +1,587 @@ +mod http_resources; + +use gateway_plugin_sdk::{ + Capability, ContributionDeclaration, Contributions, Stage, + client::{MiddlewarePlugin, PluginSession, RequestCall, SessionConfig}, +}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let session = PluginSession::accept( + tokio::io::stdin(), + tokio::io::stdout(), + SessionConfig::default(), + ) + .await?; + let mode = session.handshake().configuration["mode"] + .as_str() + .unwrap_or("map") + .to_owned(); + let configuration = session.handshake().configuration.clone(); + if mode == "mixed_mounts" { + let contributes = Contributions::from([( + Capability::Middleware, + ContributionDeclaration { + id: "test.example.middleware".into(), + version: 3, + stages: vec![ + Stage::Http, + Stage::WebSocket, + Stage::Service, + Stage::Request, + Stage::Attempt, + ], + input_formats: vec![ + "http".into(), + "websocket".into(), + "service".into(), + "openai".into(), + ], + output_formats: vec![ + "http".into(), + "websocket".into(), + "service".into(), + "openai".into(), + ], + }, + )]); + let plugin = MiddlewarePlugin::new( + &contributes, + move |call: gateway_plugin_sdk::client::MiddlewareCall| { + let records = configuration["records"].as_str().unwrap().to_owned(); + async move { + use std::io::Write as _; + let context = call.context().clone(); + let record = |phase| { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&records) + .unwrap(); + writeln!(file, "{phase}:{:?}:{}", context.stage, context.instance_id) + .unwrap(); + }; + record("enter"); + let result = call.forward().await; + record("exit"); + result + } + }, + )?; + session.run(plugin).await?; + return Ok(()); + } + let contributes = Contributions::from([( + Capability::Middleware, + ContributionDeclaration { + id: "test.example.middleware".into(), + version: configuration["middleware_version"] + .as_u64() + .unwrap_or(3) + .try_into()?, + stages: vec![if configuration["service"] == true { + Stage::Service + } else if configuration["http"] == true { + Stage::Http + } else if configuration["websocket"] == true { + Stage::WebSocket + } else if configuration["attempt"] == true { + Stage::Attempt + } else { + Stage::Request + }], + input_formats: vec![if configuration["service"] == true { + "service".into() + } else if configuration["http"] == true { + "http".into() + } else if configuration["websocket"] == true { + "websocket".into() + } else { + "openai".into() + }], + output_formats: vec![if configuration["service"] == true { + "service".into() + } else if configuration["http"] == true { + "http".into() + } else if configuration["websocket"] == true { + "websocket".into() + } else { + "openai".into() + }], + }, + )]); + if configuration["service"] == true { + let plugin = MiddlewarePlugin::new( + &contributes, + move |call: gateway_plugin_sdk::client::ServiceCall| { + let mode = mode.clone(); + let records = configuration["records"] + .as_str() + .map(std::path::PathBuf::from); + async move { + if mode == "onion" { + use std::io::Write as _; + let records = records.unwrap(); + let mut event = serde_json::json!({ + "instance":call.context.instance_id, + "operation":call.operation, + "request_id":call.request_id, + "call_id":call.call_id, + "parent_call_id":call.parent_call_id, + }); + let mut record = |phase| { + event["phase"] = serde_json::json!(phase); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&records) + .unwrap(); + writeln!(file, "{event}").unwrap(); + }; + record("enter"); + let result = call.forward().await; + record("exit"); + return result; + } + if mode == "fault" { + let mut fault = gateway_plugin_sdk::PluginFault::new( + gateway_plugin_sdk::ErrorCode::Capacity, + "service plugin fixture error", + ); + fault.details = Some( + serde_json::json!({"opaque":"unfiltered","empty":"","zero":0,"disabled":false,"missing":null}), + ); + return Err(fault); + } + if let Some(records) = &records { + use std::io::Write as _; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(records) + .unwrap(); + writeln!(file, "{}", serde_json::json!({"operation":call.operation,"request_id":call.request_id,"call_id":call.call_id,"parent_call_id":call.parent_call_id})).unwrap(); + } + if mode == "block" { + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + std::fs::write(self.0.with_extension("closed"), b"closed").unwrap(); + } + } + let _cleanup = Cleanup(records.unwrap()); + call.cancellation.cancelled().await; + return Err(gateway_plugin_sdk::PluginFault::new( + gateway_plugin_sdk::ErrorCode::Cancelled, + "cancelled", + )); + } + use gateway_plugin_sdk::{call::services::settings, client::ServiceResponse}; + if call.is::() && mode == "recover" { + return ServiceResponse::from_result::(Ok(true)); + } + if call.is::() { + let call = call.into_typed::()?; + // 同一插件再次读取服务不会递归调用自身;回调仍经过宿主服务端口。 + let nested = call.host.service::(()).await.unwrap(); + let mut output = call.next.run(()).await.unwrap(); + assert_eq!(output.config_revision, nested.config_revision); + if mode == "rewrite" { + output.request_interval_ms = 4321; + } + return ServiceResponse::from_result::(Ok(output)); + } + if call.is::() { + let mut call = call.into_typed::()?; + call.input.1.request_interval_ms = 1234; + return ServiceResponse::from_result::( + call.next.run(call.input).await, + ); + } + if call.is::() && mode == "recover" { + let call = call.into_typed::()?; + let error = call.next.run(call.input).await.unwrap_err(); + assert_eq!(error.kind, "invalid"); + assert!(!error.message.is_empty()); + return ServiceResponse::from_result::(Ok( + serde_json::Map::new(), + )); + } + call.forward().await + } + }, + )?; + session.run(plugin).await?; + return Ok(()); + } + if configuration["websocket"] == true { + let plugin = MiddlewarePlugin::new( + &contributes, + move |call: gateway_plugin_sdk::client::WebSocketCall| { + let mode = mode.clone(); + async move { + use gateway_plugin_sdk::client::{WebSocketKind, WebSocketMessage}; + assert!( + call.headers + .iter() + .any(|header| header.name == "authorization" + && header.value == b"Bearer fixture-secret") + ); + if mode == "control" { + assert_eq!(call.message.payload.collect().await?, b"custom.control"); + call.sender + .send(WebSocketMessage::new( + WebSocketKind::Binary, + vec![0, 255, 1], + )) + .await?; + return Ok(None); + } + let mut message = call.message; + if mode == "map" { + let bytes = message.payload.collect().await?.to_ascii_uppercase(); + message = WebSocketMessage::new(message.kind, bytes); + } + let result = call.next.run(message).await?; + Ok(result) + } + }, + )?; + session.run(plugin).await?; + return Ok(()); + } + if configuration["http"] == true { + let plugin = MiddlewarePlugin::new( + &contributes, + move |mut call: gateway_plugin_sdk::client::HttpCall| { + let mode = mode.clone(); + async move { + use gateway_plugin_sdk::client::{HttpBody, HttpFrame, HttpResponse}; + if mode == "upload_resources" { + http_resources::check_upload_lifecycle(&call.host).await?; + return call.next.run(call.request).await; + } + if mode == "sequential_uploads" { + for index in 0_u32..40 { + let expected = index.to_be_bytes().to_vec(); + let request = gateway_plugin_sdk::client::HttpRequest { + settings: call.request.settings.clone(), + method: "POST".into(), + uri: "/child".into(), + version: call.request.version, + headers: Vec::new(), + timeout_ms: call.request.timeout_ms, + body: HttpBody::from_bytes(expected.clone()) + .map_frames(|frame| Ok(Some(frame))), + }; + let mut response = call.host.dispatch_http(request).await?; + let mut bytes = Vec::new(); + while let Some(frame) = response.body.read().await? { + match frame { + HttpFrame::Data(chunk) => bytes.extend(chunk), + HttpFrame::Trailers(_) => panic!("unexpected trailers"), + } + } + assert_eq!(bytes, expected); + assert!(response.body.read().await?.is_none()); + response.body.close().await?; + } + return call.next.run(call.request).await; + } + if mode == "close_response_body" || mode == "read_response_body" { + let mut response = call.next.run(call.request).await?; + if mode == "read_response_body" { + assert!( + matches!(response.body.read().await?, Some(HttpFrame::Data(bytes)) if bytes == b"original") + ); + assert!(response.body.read().await?.is_none()); + return Ok(response); + } + response.body.close().await?; + response.body.close().await?; + response.body = HttpBody::from_bytes(b"replacement".to_vec()); + return Ok(response); + } + if mode == "settings" { + if call.context.instance_id == "dispatch-a" { + assert_eq!(call.request.timeout_ms, Some(60_000)); + call.request.timeout_ms = Some(90_000); + assert!(call.request.settings["min_codex_cli_version"].is_null()); + call.request.settings["min_codex_cli_version"] = + serde_json::json!("0.40.0"); + call.request.settings["request_interval_ms"] = serde_json::json!(0); + } else { + assert_eq!(call.request.timeout_ms, Some(90_000)); + assert_eq!( + call.settings_sources["http_timeout"]["change"]["instance_id"], + "dispatch-a" + ); + call.request.timeout_ms = None; + assert_eq!(call.request.settings["min_codex_cli_version"], "0.40.0"); + assert_eq!( + call.settings_sources["overrides"]["min_codex_cli_version"]["instance_id"], + "dispatch-a" + ); + call.request.settings["min_codex_cli_version"] = + serde_json::Value::Null; + call.request.settings["responses_max_decompressed_body_bytes"] = + serde_json::json!(8192); + } + return call.host.dispatch_http(call.request).await; + } + if mode == "callbacks" { + let host = call.host.clone(); + return call.upgrade(vec!["test-session".into()], |mut session| async move { + use gateway_plugin_sdk::client::{WebSocketKind, WebSocketMessage, SessionError}; + session.sender.send(WebSocketMessage::new(WebSocketKind::Text, b"ready".to_vec())).await?; + while let Some(message) = session.receive().await? { + let command: serde_json::Value = serde_json::from_slice(&message.payload.collect().await?).unwrap(); + let payload = command.get("body").map(|body| serde_json::to_vec(body).unwrap()).unwrap_or_default(); + let result = host.call(command["method"].as_str().unwrap(), command["params"].clone(), payload).await.map_err(SessionError::into_plugin_fault)?; + let reply = serde_json::json!({"result":result.result,"payload_bytes":result.payload.len()}); + session.sender.send(WebSocketMessage::new(WebSocketKind::Text, serde_json::to_vec(&reply).unwrap())).await?; + } + Ok(()) + }).await; + } + if mode == "upgrade" { + return call.upgrade(vec!["test-session".into()], |mut session| async move { + use gateway_plugin_sdk::client::{WebSocketKind, WebSocketMessage}; + let sender = session.sender.clone(); + for index in 0..3 { + let receive = session.receive(); + tokio::pin!(receive); + tokio::select! { + message = &mut receive => panic!("receive must wait before greeting: {}", message.is_ok()), + () = tokio::time::sleep(std::time::Duration::from_millis(20)) => {}, + } + if index == 0 { + sender.send(WebSocketMessage::new(WebSocketKind::Text, b"ready".to_vec())).await?; + } + // 模拟模型任务先完成导致 select 丢弃 receive;下一次等待必须继续取同一条消息。 + } + if let Some(message) = session.receive().await? { sender.send(message).await?; } + for index in 0..32 { + let Some(mut message) = session.receive().await? else { break; }; + let payload = if index % 2 == 0 { + message.payload.collect().await? + } else { + let bytes = message.payload.read().await?.unwrap_or_default(); + message.payload.close().await?; + bytes + }; + sender.send(WebSocketMessage::new(message.kind, payload)).await?; + } + session.close(Some(1000), "done".into()).await + }).await; + } + if mode == "dispatch" || mode == "dispatch_upload" { + call.request.headers.push( + gateway_plugin_sdk::call::middleware::MiddlewareHeader { + name: "x-dispatch-instance".into(), + value: call.context.instance_id.as_bytes().to_vec(), + }, + ); + if mode == "dispatch_upload" { + call.request.body = call.request.body.map_frames(|frame| { + Ok(Some(match frame { + HttpFrame::Data(bytes) => { + HttpFrame::Data(bytes.to_ascii_uppercase()) + } + HttpFrame::Trailers(headers) => HttpFrame::Trailers(headers), + })) + }); + } + return call.host.dispatch_http(call.request).await; + } + if mode == "identity" { + return call.next.run(call.request).await; + } + if mode == "short" { + return Ok(HttpResponse::new( + 418, + HttpBody::from_bytes(b"plugin route".to_vec()), + )); + } + assert!( + call.request + .headers + .iter() + .any(|header| header.name == "authorization" + && header.value == b"Bearer fixture-secret") + ); + call.request.uri = "/rewritten?encoded=%2F".into(); + call.request.method = "PATCH".into(); + call.request.timeout_ms = None; + if mode == "upload" { + call.request.body = call.request.body.map_frames(|frame| { + Ok(Some(match frame { + HttpFrame::Data(bytes) => { + HttpFrame::Data(bytes.to_ascii_uppercase()) + } + HttpFrame::Trailers(headers) => HttpFrame::Trailers(headers), + })) + }); + } + let mut response = call.next.run(call.request).await?; + response + .headers + .push(gateway_plugin_sdk::call::middleware::MiddlewareHeader { + name: "x-http-plugin".into(), + value: b"active".to_vec(), + }); + if mode == "map" { + response.body = response.body.map_frames(|frame| { + Ok(Some(match frame { + HttpFrame::Data(bytes) => { + HttpFrame::Data(bytes.to_ascii_uppercase()) + } + HttpFrame::Trailers(headers) => HttpFrame::Trailers(headers), + })) + }); + } + Ok(response) + } + }, + )?; + session.run(plugin).await?; + return Ok(()); + } + let plugin = MiddlewarePlugin::new(&contributes, move |mut call: RequestCall| { + let mode = mode.clone(); + let configuration = configuration.clone(); + async move { + if mode == "declare" { + call.request + .replace_body(serde_json::to_vec(&configuration["body"]).unwrap()); + call.request.declare_capabilities( + serde_json::from_value(configuration["capabilities"].clone()).unwrap(), + ); + } + if mode == "credentials" { + assert!( + call.request + .head + .headers + .iter() + .any(|header| header.name == "authorization" + && header.value == b"Bearer private") + ); + assert!( + call.request + .head + .headers + .iter() + .any(|header| header.name == "connection") + ); + call.request.remove_header("authorization"); + call.request + .append_header("authorization", b"Bearer rewritten".to_vec()); + } + if mode == "headers" { + call.request + .append_header("x-sdk-request", b"active".to_vec()); + } + if mode == "settings" { + assert_eq!( + call.request.head.settings, + configuration["expected_settings"] + ); + if let Some(settings) = configuration.get("settings") { + call.request.head.settings = settings.clone(); + } + } + if mode == "inspect" && !call.request.body.is_empty() { + // 读取和解析只能作用于副本,不能把解析结果写回未改写的正文。 + assert!(serde_json::from_slice::(&call.request.body).is_ok()); + } + if mode == "rejected" { + let mut fault = gateway_plugin_sdk::PluginFault::new( + gateway_plugin_sdk::ErrorCode::Rejected, + "plugin rejection details", + ); + fault.details = Some(serde_json::json!({"reason":"fixture","disabled":false})); + return Err(fault); + } + if mode == "downstream_error" { + let error = match call.next.run(call.request).await { + Ok(_) => panic!("expected downstream failure"), + Err(error) => error, + }; + let fault = &error; + assert_eq!( + fault.message, + configuration["expected_message"].as_str().unwrap() + ); + if configuration["remote_error"] == true { + assert_eq!( + fault.details.as_ref().unwrap(), + &configuration["expected_details"] + ); + assert_eq!(fault.code, gateway_plugin_sdk::ErrorCode::Rejected); + } else { + assert_eq!( + fault.details.as_ref().unwrap()["provider"]["response"]["body"], + configuration["expected_body"] + ); + assert_eq!(fault.http_status, Some(429)); + assert_eq!(fault.send_state, gateway_plugin_sdk::SendState::Sent); + } + return Err(error); + } + let mut response = call.next.run(call.request).await?; + match mode.as_str() { + "facts" => { + let path = configuration["facts_marker"].as_str().unwrap().to_owned(); + std::fs::write( + &path, + format!("{}\n", serde_json::to_string(&response.metadata).unwrap()), + ) + .unwrap(); + // 修改快照不能替换原执行的账号或费用。 + response.metadata.as_mut().unwrap().provider_account_id = + "changed-snapshot".into(); + response.body = response.body.with_facts()?.map_frames(move |mut frame| { + use std::io::Write; + let facts = frame.facts.as_mut().unwrap(); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!(file, "{}", serde_json::to_string(facts).unwrap()).unwrap(); + facts.host.as_mut().unwrap().costs.clear(); + Ok(vec![frame]) + })?; + } + "passthrough" | "declare" | "settings" => {} + "inspect" => { + response.body = response.body.inspect_frames(|frame| { + assert!(!frame.payload.is_empty()); + })?; + } + "credentials" => { + assert!( + response + .headers + .iter() + .any(|header| header.name == "set-cookie" + && header.value == b"test-hidden=kept") + ); + response.remove_header("set-cookie"); + response.append_header("set-cookie", b"rewritten=true".to_vec()); + } + "headers" => response.append_header("x-sdk-response", b"active".to_vec()), + _ => { + response.append_header("x-sdk-plugin", b"active".to_vec()); + response.body = response.body.map_frames(|mut frame| { + frame.payload.push(b' '); + Ok(vec![frame]) + })?; + } + } + Ok(response) + } + })?; + session.run(plugin).await?; + Ok(()) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs index 917a370fc..5b118877d 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs @@ -46,9 +46,7 @@ pub fn contribution_for_id( Capability::RetryPolicy => "retryPolicy", Capability::Middleware => "middleware", Capability::UpstreamAdapter => "upstreamAdapter", - Capability::RequestLifecycle => "requestLifecycle", - Capability::WebSocketObserver => "webSocketObserver", - Capability::Usage => "usage", + Capability::Observer => "observer", Capability::CommandLine => "commandLine", Capability::Management => "management", Capability::Maintenance => "maintenance", @@ -57,7 +55,11 @@ pub fn contribution_for_id( capability, ContributionDeclaration { id: format!("{plugin_id}.{local_id}"), - version: 1, + version: if capability == Capability::Middleware { + 3 + } else { + 1 + }, stages, input_formats, output_formats, @@ -85,46 +87,35 @@ pub fn worker() -> &'static [u8] { } pub fn package(worker: &[u8]) -> Arc<[u8]> { - package_with_permissions(worker, Vec::new()) + package_with_contributions(worker, Contributions::new()) } -pub fn package_with_permissions( - worker: &[u8], - permissions: Vec, -) -> Arc<[u8]> { - package_with_contributions(worker, permissions, Contributions::new()) -} - -pub fn package_with_contributions( - worker: &[u8], - permissions: Vec, - contributes: Contributions, -) -> Arc<[u8]> { - package_with_contributions_and_state(worker, permissions, contributes, vec![]) +pub fn package_with_contributions(worker: &[u8], contributes: Contributions) -> Arc<[u8]> { + package_with_contributions_and_state(worker, contributes, vec![]) } pub fn package_with_contributions_for_id( worker: &[u8], plugin_id: &str, - permissions: Vec, + contributes: Contributions, ) -> Arc<[u8]> { - package_with_identity_and_state(worker, plugin_id, permissions, contributes, vec![]) + package_with_identity_and_state(worker, plugin_id, contributes, vec![]) } pub fn package_with_contributions_and_state( worker: &[u8], - permissions: Vec, + contributes: Contributions, state: Vec, ) -> Arc<[u8]> { - package_with_identity_and_state(worker, DEFAULT_PLUGIN_ID, permissions, contributes, state) + package_with_identity_and_state(worker, DEFAULT_PLUGIN_ID, contributes, state) } fn package_with_identity_and_state( worker: &[u8], plugin_id: &str, - permissions: Vec, + contributes: Contributions, state: Vec, ) -> Arc<[u8]> { @@ -152,7 +143,7 @@ fn package_with_identity_and_state( main: "bin/worker".to_owned(), runtime: RuntimeKind::TrustedProcess, contributes, - permissions: permissions.into_iter().collect::>(), + configuration_schema: serde_json::json!({}), secret_fields: BTreeSet::new(), resources: BTreeMap::new(), diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs index 3497e6a14..2b97f892e 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs @@ -129,9 +129,16 @@ impl Peer { method: &str, value: Value, ) -> Result { - let (result, payload) = self + let reply = self .callback_payload(id, method, json!({}), serde_json::to_vec(&value).unwrap()) - .await?; + .await; + if self.configuration["maintenance_fixture"] == true { + self.append_observation_marker( + "maintenance_marker", + &json!({"phase":"callback", "method":method, "error":reply.as_ref().err().map(|error| error.code)}), + ); + } + let (result, payload) = reply?; assert_eq!(result, json!({})); Ok(serde_json::from_slice(&payload).unwrap()) } @@ -559,13 +566,16 @@ impl Peer { self.send(Message::Error { id, error }, vec![]).await; return; } - let response = self + let mut response = self .configuration .get("management_response") .cloned() .unwrap_or_else( || json!({"status":200,"content_type":"application/octet-stream"}), ); + if self.configuration["management_echo_headers"] == true { + response["headers"] = serde_json::to_value(request.headers).unwrap(); + } self.send( Message::Result { id, @@ -577,6 +587,8 @@ impl Peer { return; } "management.callback" => { + let request: gateway_plugin_sdk::call::management::ManagementRequest = + serde_json::from_value(params).unwrap(); for method in [ "host.auth.list", "host.auth.save", @@ -585,13 +597,13 @@ impl Peer { "host.log", ] { assert!( - matches!(self.callback(id, method, json!({})).await, Err(error) if error.code == ErrorCode::PermissionDenied) + !matches!(self.callback(id, method, json!({})).await, Err(error) if error.code == ErrorCode::PermissionDenied) ); } self.send( Message::Result { id, - result: json!({"status":200,"content_type":"text/plain"}), + result: json!({"status":200,"content_type":"text/plain","headers":request.headers}), }, b"callback received".to_vec(), ) @@ -603,7 +615,7 @@ impl Peer { for method in ["host.http.do", "host.auth.save", "host.state.put"] { let reply = self.callback(id, method, json!({})).await; assert!( - matches!(reply, Err(ref error) if error.code == ErrorCode::PermissionDenied) + !matches!(reply, Err(ref error) if error.code == ErrorCode::PermissionDenied) ); } } @@ -657,7 +669,20 @@ impl Peer { .await; return; } - let result = if let Some(results) = self.data_queries(id).await { + let result = if let Some(request) = + self.configuration.get("command_service_request") + { + let (result, payload) = self + .callback( + id, + gateway_plugin_sdk::call::services::CALL_METHOD, + request.clone(), + ) + .await + .unwrap(); + assert!(payload.is_empty()); + json!({"stdout":serde_json::to_string(&result).unwrap(),"stderr":"","exit_code":0}) + } else if let Some(results) = self.data_queries(id).await { json!({"stdout":serde_json::to_string(&results).unwrap(),"stderr":"","exit_code":0}) } else if self.configuration["command_echo"] == true { json!({"stdout":serde_json::to_string(&invocation).unwrap(),"stderr":"typed command\n","exit_code":0}) @@ -835,7 +860,6 @@ impl Peer { "mount":request.mount, "protocol":request.protocol, "headers":request.headers, - "body_visible":request.body_visible, "body":String::from_utf8_lossy(&payload), }), ); @@ -860,6 +884,7 @@ impl Peer { id, NEXT_METHOD, serde_json::to_value(MiddlewareNextRequest { + settings: None, protocol: None, header_mutations: Vec::new(), body: MiddlewareRequestBody::Preserve, @@ -953,195 +978,198 @@ impl Peer { self.send(Message::End { id, error: None }, vec![]).await; return; } - "websocket.response_event" => { - let observation: gateway_plugin_sdk::call::observation::ObserveWebSocketResponse = - serde_json::from_value(params).unwrap(); - let payload_text = (payload.len() <= 4096) - .then(|| std::str::from_utf8(&payload).ok()) - .flatten(); - let marked = json!({ - "label": self.configuration.get("observation_label"), - "observation": observation, - "payload_bytes": payload.len(), - "payload_text": payload_text, - }); - self.append_observation_marker("websocket_observation_started_marker", &marked); - if let Some(delay) = self - .configuration - .get("websocket_observation_delay_ms") - .and_then(Value::as_u64) - { - tokio::time::sleep(Duration::from_millis(delay)).await; - } - self.append_observation_marker("websocket_observation_marker", &marked); - if self.configuration.get("websocket_observation_fault") == Some(&Value::Bool(true)) - { + "observer.observe" => match serde_json::from_value::< + gateway_plugin_sdk::call::observation::Event, + >(params) + .unwrap() + { + gateway_plugin_sdk::call::observation::Event::WebSocketResponse(observation) => { + let payload_text = (payload.len() <= 4096) + .then(|| std::str::from_utf8(&payload).ok()) + .flatten(); + let marked = json!({ + "label": self.configuration.get("observation_label"), + "observation": observation, + "payload_bytes": payload.len(), + "payload_text": payload_text, + }); + self.append_observation_marker("websocket_observation_started_marker", &marked); + if let Some(delay) = self + .configuration + .get("websocket_observation_delay_ms") + .and_then(Value::as_u64) + { + tokio::time::sleep(Duration::from_millis(delay)).await; + } + self.append_observation_marker("websocket_observation_marker", &marked); + if self.configuration.get("websocket_observation_fault") + == Some(&Value::Bool(true)) + { + self.send( + Message::Error { + id, + error: PluginFault::new( + ErrorCode::Fault, + "fixture WebSocket observation failure", + ), + }, + vec![], + ) + .await; + return; + } self.send( - Message::Error { + Message::Result { id, - error: PluginFault::new( - ErrorCode::Fault, - "fixture WebSocket observation failure", - ), + result: Value::Null, }, vec![], ) .await; return; } - self.send( - Message::Result { - id, - result: Value::Null, - }, - vec![], - ) - .await; - return; - } - "policy.observe_request" => { - let observation: gateway_plugin_sdk::call::policy::ObserveRequest = - serde_json::from_slice(&payload).unwrap(); - let marked = json!({ - "label": self.configuration.get("observation_label"), - "observation": observation, - }); - self.append_observation_marker("observation_started_marker", &marked); - if let Some(fixture) = self.configuration.get("state_fixture") { - let namespace = fixture["namespace"].as_str().unwrap(); - let key = fixture["key"].as_str().unwrap(); - let value = fixture["value"].clone(); - let put = gateway_plugin_sdk::call::host::StatePutRequest { - namespace: namespace.into(), - key: key.into(), - value: value.clone(), - expected_version: None, - }; - let put = match self - .callback(id, "host.state.put", serde_json::to_value(put).unwrap()) - .await - { - Ok((result, payload)) if payload.is_empty() => serde_json::from_value::< - gateway_plugin_sdk::call::host::StatePutResult, - >( - result - ) - .unwrap(), - Ok(_) => panic!("state put returned an unexpected payload"), - Err(error) => { - self.send(Message::Error { id, error }, vec![]).await; - return; + gateway_plugin_sdk::call::observation::Event::RequestCompleted(observation) => { + let marked = json!({ + "label": self.configuration.get("observation_label"), + "observation": observation, + }); + self.append_observation_marker("observation_started_marker", &marked); + if let Some(fixture) = self.configuration.get("state_fixture") { + let namespace = fixture["namespace"].as_str().unwrap(); + let key = fixture["key"].as_str().unwrap(); + let value = fixture["value"].clone(); + let put = gateway_plugin_sdk::call::host::StatePutRequest { + namespace: namespace.into(), + key: key.into(), + value: value.clone(), + expected_version: None, + }; + let put = match self + .callback(id, "host.state.put", serde_json::to_value(put).unwrap()) + .await + { + Ok((result, payload)) if payload.is_empty() => { + serde_json::from_value::< + gateway_plugin_sdk::call::host::StatePutResult, + >(result) + .unwrap() + } + Ok(_) => panic!("state put returned an unexpected payload"), + Err(error) => { + self.send(Message::Error { id, error }, vec![]).await; + return; + } + }; + let get = gateway_plugin_sdk::call::host::StateGetRequest { + namespace: namespace.into(), + key: key.into(), + }; + let get = match self + .callback(id, "host.state.get", serde_json::to_value(get).unwrap()) + .await + { + Ok((result, payload)) if payload.is_empty() => { + serde_json::from_value::< + gateway_plugin_sdk::call::host::StateGetResult, + >(result) + .unwrap() + } + Ok(_) => panic!("state get returned an unexpected payload"), + Err(error) => { + self.send(Message::Error { id, error }, vec![]).await; + return; + } + }; + let record = get.record.unwrap(); + assert_eq!(record.value, value); + assert_eq!(record.version, put.version); + if let Some(denied_namespace) = + fixture.get("denied_namespace").and_then(Value::as_str) + { + let denied = self + .callback( + id, + "host.state.get", + serde_json::to_value( + gateway_plugin_sdk::call::host::StateGetRequest { + namespace: denied_namespace.into(), + key: key.into(), + }, + ) + .unwrap(), + ) + .await; + assert!(matches!( + denied, + Err(error) if error.code == ErrorCode::PermissionDenied + )); } - }; - let get = gateway_plugin_sdk::call::host::StateGetRequest { - namespace: namespace.into(), - key: key.into(), - }; - let get = match self - .callback(id, "host.state.get", serde_json::to_value(get).unwrap()) - .await - { - Ok((result, payload)) if payload.is_empty() => serde_json::from_value::< - gateway_plugin_sdk::call::host::StateGetResult, - >( - result - ) - .unwrap(), - Ok(_) => panic!("state get returned an unexpected payload"), - Err(error) => { - self.send(Message::Error { id, error }, vec![]).await; - return; - } - }; - let record = get.record.unwrap(); - assert_eq!(record.value, value); - assert_eq!(record.version, put.version); - if let Some(denied_namespace) = - fixture.get("denied_namespace").and_then(Value::as_str) - { - let denied = self + let deleted = match self .callback( id, - "host.state.get", + "host.state.delete", serde_json::to_value( - gateway_plugin_sdk::call::host::StateGetRequest { - namespace: denied_namespace.into(), + gateway_plugin_sdk::call::host::StateDeleteRequest { + namespace: namespace.into(), key: key.into(), + expected_version: put.version, }, ) .unwrap(), ) - .await; - assert!(matches!( - denied, - Err(error) if error.code == ErrorCode::PermissionDenied - )); - } - let deleted = match self - .callback( - id, - "host.state.delete", - serde_json::to_value( - gateway_plugin_sdk::call::host::StateDeleteRequest { - namespace: namespace.into(), - key: key.into(), - expected_version: put.version, - }, - ) - .unwrap(), - ) - .await - { - Ok((result, payload)) if payload.is_empty() => { - serde_json::from_value::< - gateway_plugin_sdk::call::host::StateDeleteResult, - >(result) - .unwrap() - } - Ok(_) => panic!("state delete returned an unexpected payload"), - Err(error) => { - self.send(Message::Error { id, error }, vec![]).await; - return; - } - }; - assert!(deleted.deleted); - self.append_observation_marker( + .await + { + Ok((result, payload)) if payload.is_empty() => { + serde_json::from_value::< + gateway_plugin_sdk::call::host::StateDeleteResult, + >(result) + .unwrap() + } + Ok(_) => panic!("state delete returned an unexpected payload"), + Err(error) => { + self.send(Message::Error { id, error }, vec![]).await; + return; + } + }; + assert!(deleted.deleted); + self.append_observation_marker( "state_marker", &json!({"version": put.version, "schema_version": record.schema_version}), ); - } - if let Some(delay) = self - .configuration - .get("observation_delay_ms") - .and_then(Value::as_u64) - { - tokio::time::sleep(Duration::from_millis(delay)).await; - } - self.append_observation_marker("observation_marker", &marked); - if self.configuration.get("observation_fault") == Some(&Value::Bool(true)) { + } + if let Some(delay) = self + .configuration + .get("observation_delay_ms") + .and_then(Value::as_u64) + { + tokio::time::sleep(Duration::from_millis(delay)).await; + } + self.append_observation_marker("observation_marker", &marked); + if self.configuration.get("observation_fault") == Some(&Value::Bool(true)) { + self.send( + Message::Error { + id, + error: PluginFault::new( + ErrorCode::Fault, + "fixture observation failure", + ), + }, + vec![], + ) + .await; + return; + } self.send( - Message::Error { + Message::Result { id, - error: PluginFault::new( - ErrorCode::Fault, - "fixture observation failure", - ), + result: Value::Null, }, vec![], ) .await; return; } - self.send( - Message::Result { - id, - result: Value::Null, - }, - vec![], - ) - .await; - return; - } + }, "slow" => tokio::time::sleep(Duration::from_millis(400)).await, "hang" | "hang_uncancellable" => return, "crash" => std::process::exit(7), @@ -1433,36 +1461,46 @@ impl Peer { .into_iter() .flatten() { - let payload = if callback["body"] == "request" { - body.clone() - } else { - vec![] - }; - let (result, payload) = self - .callback_payload( + let callbacks = callback + .as_array() + .map_or_else(|| std::slice::from_ref(callback), Vec::as_slice); + let results = futures::future::try_join_all(callbacks.iter().map(|callback| { + let payload = if callback["body"] == "request" { + body.clone() + } else { + callback["body"] + .as_str() + .unwrap_or_default() + .as_bytes() + .to_vec() + }; + self.callback_payload( id, callback["method"].as_str().unwrap(), callback["params"].clone(), payload, ) - .await?; - if let Some(stream) = result["stream"].as_str() { - loop { - let (result, chunk) = self - .callback_payload( - id, - "host.upstream.http.stream_read", - json!({"stream":stream,"maximum_bytes":65536}), - vec![], - ) - .await?; - response_body.extend(chunk); - if result["eof"] == true { - break; + })) + .await?; + for (result, payload) in results { + if let Some(stream) = result["stream"].as_str() { + loop { + let (result, chunk) = self + .callback_payload( + id, + "host.upstream.http.stream_read", + json!({"stream":stream,"maximum_bytes":65536}), + vec![], + ) + .await?; + response_body.extend(chunk); + if result["eof"] == true { + break; + } } + } else if !payload.is_empty() { + response_body = payload; } - } else if !payload.is_empty() { - response_body = payload; } } let events = self.configuration["upstream_events"].as_array().unwrap(); diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index 54ff5ca19..1b9f69b2f 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -1,15 +1,15 @@ # Gateway Plugin SDK 用于编写 Codex Proxy 插件的 Rust 合同与可选会话辅助。插件只依赖本包,不依赖宿主 Core、Admin、Store 或 Host。 -SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声明、实际注册和安装时接受的访问域, +SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声明与实际注册, 而不只是枚举中存在该名称 | 版本 | 当前值 | 用途 | | --- | --- | --- | | SDK 包 | `0.1.0` | Rust 开发依赖,独立于网关版本 | -| `manifestVersion` | `1` | 安装清单格式 | -| `package.protocolVersion` | `1` | 宿主与插件的进程通信 | -| `contributes.*.version` | 默认 `1`,`middleware` 可选 `2` | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) | +| `manifestVersion` | `2` | 安装清单格式 | +| `package.protocolVersion` | `2` | 宿主与插件的进程通信 | +| `contributes.*.version` | 默认 `1`,`middleware` 必须为 `3` | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) | 开发顺序:编写[清单](docs/manifest.md) → 实现[能力处理器](docs/capabilities.md) → 用 [CLI](../../../apps/plugin-cli/README.md) 打包 → [安装、配置与启用](../../../../docs/plugins.md)。完整可运行示例位于独立仓库 @@ -24,19 +24,20 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声 | 根门面 | 清单、能力声明、调用上下文、消息与帧、公开错误 | | `call::frontend_authentication` | 数据面认证信封、认证器标识与外部 principal 结果 | | `call::model` | 宿主模型调用的 canonical 事实、原生 wire 与有界事件编解码 | -| `call::middleware` | 洋葱中间件请求/响应 head、single-use next 与惰性正文 frame 合同 | +| `call::middleware` | HTTP、WebSocket 消息、公开服务、模型请求及 attempt 的洋葱调用、单次 next 与惰性正文合同 | +| `call::services` | 公开服务的类型化操作、输入输出及错误合同 | | `call::upstream_adapter` | 内置 OpenAI / xAI 的[受管上游适配器](docs/upstream-adapters.md)注册、执行、出站与续接合同 | -| `call::policy` | 模型路由、账号调度、受约束重试及终态用量观察合同 | +| `call::policy` | 模型路由、账号调度与重试决策合同 | | `call::catalog` | 固定 Provider 的模型别名注册合同 | | `call::data` | 最小账号事实与已有额度观测的只读合同 | -| `call::observation` | 实际上游 WebSocket 响应帧的只读观察合同 | +| `call::observation` | 请求完成、用量、费用与实际上游 WebSocket 响应事件的统一观察合同 | | `call::host` | 账号、私有状态、受管 HTTP、模型、亲和、日志及调用内流的宿主回调合同 | | `call::management` | 管理 API、页面与资源声明、公开回调,以及 CLI 命令与待保存账号 | | `client` | 通过 `io` feature 开启的 `PluginBuilder`、会话、中间件与异步帧收发 | `call/` 按业务能力组织数据,`message.rs` 定义消息与帧。已有请求链的请求/响应改写、协议转换和思考参数映射使用 `call::middleware`;需要指定业务上游并解析结果时使用 `call::upstream_adapter`,作为洋葱链终端复用宿主账号与结算。 -WebSocket 观察接口不能改写连接 +下游 WebSocket 改写使用 `websocket` 中间件;上游 WebSocket 的观察接口保留只读合同 插件使用的导入路径例如: @@ -51,11 +52,11 @@ use gateway_plugin_sdk::client::{read_frame, write_frame}; `PluginSession::accept/run` 和 `PluginHandler`。组合插件从作者清单建立类型化方法,SDK 自动生成 `plugin.register`;作者不需要复制贡献项或手写方法名、阶段和敏感载荷编解码。 会话管理握手、有界并发、父调用关联、回调、流信用、取消、排空与关闭。 -`ResponseStream::channel` 提供有界流生产入口;`PluginCall` 的宿主客户端只代表当前父调用, -不能用另一调用的 ID 扩大权限。插件程序自行选择运行时和启动入口,不会启动网关。 +`ResponseStream::channel` 提供有界流生产入口,`ResponseStream::pull` 按消费进度驱动生产者;`PluginCall` 的宿主客户端只代表当前父调用, +不能把一个调用的资源误用到另一个调用。插件程序自行选择运行时和启动入口,不会启动网关。 `SessionConfig.maximum_stream_chunk_bytes` 默认 1 MiB,只约束单个业务流分块; 流式响应还需满足宿主实际授予的信用窗口。普通调用与回调的正文不受此预算限制。 -业务处理器仍接收完整正文,内存占用随正文增长 +普通业务处理器仍接收完整正文,内存占用随正文增长;HTTP 与下游 WebSocket 中间件使用惰性正文句柄,透传不复制正文 ## 通信边界 @@ -72,7 +73,7 @@ sequenceDiagram H->>P: 注册能力,核对清单 P-->>H: 注册结果 H->>P: Call(ID、上下文、载荷) - opt 需要宿主资源且已获授权 + opt 需要宿主资源 P->>H: 回调(关联父调用 ID) H-->>P: 回调结果 end @@ -81,22 +82,34 @@ sequenceDiagram ### 帧与会话 -协议 v1 的帧头由大端 `u32` 元数据长度和大端 `u64` 正文长度组成,随后依次传输 JSON 元数据和原始二进制正文。 +`CallContext.timeout_ms` 限定初始处理期限。宿主对由流或连接 owner 管理的调用设置 `resource_stream`: +首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。 +普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束 + +协议 v2 的帧头由大端 `u32` 元数据长度和大端 `u64` 正文长度组成,随后依次传输 JSON 元数据和原始二进制正文。 元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块, 不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付 -`read_frame` / `write_frame` 使用协议 v1。帧写入队列之前先用 `validate_frame` 校验元数据, +`read_frame` / `write_frame` 使用协议 v2。帧写入队列之前先用 `validate_frame` 校验元数据, 避免本地输入错误关闭共享传输。协议校验不会自动重试业务调用;部分读写中断后不能把当前位置当作新帧边界。 底层 `read_frame` / `write_frame` 不是取消安全的;自行编排会话时,不能在 `select!` 的其他分支完成后 重建半途中的帧读写。`PluginSession` 会在处理调用完成通知时保留同一个读取 future,直到完整帧到达或会话关闭 会话由宿主发送 `Hello`,插件校验协议后返回携带相同 incarnation 的 `Ready`。 注册结果必须与清单中的能力一致。一次 `Call` 的结果、错误和流帧按 ID 关联;回调必须 -携带当前父调用 ID。实例、代次、阶段、账号或资源 ID 是关联数据,不能自行充当授权 +携带当前父调用 ID。实例、代次、阶段、账号或资源 ID 用于关联调用和管理资源生命周期 ## 本地验证 -从仓库根目录执行: +设置服务的 SDK 类型和操作由宿主领域类型及[操作声明](../../gateway-admin/src/service/settings.rs)生成,SDK 构建不依赖宿主源码。 +修改宿主合同后,在仓库根目录更新生成文件;Admin 合同测试会检查生成结果是否同步: + +```bash +CPR_UPDATE_SERVICE_CONTRACT=1 RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-admin --test main sdk_settings_contract_matches_host_declarations --locked +cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml +``` + +SDK 自身的验证命令: ```bash RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p gateway-plugin-sdk --no-default-features --locked @@ -106,4 +119,4 @@ RUST_MIN_STACK=16777216 RUSTDOCFLAGS='-D warnings' cargo +1.97.0 doc --manifest- 源码中的类型和字段是当前合同;宿主接入、目录边界与 Runtime 集成验证见 [系统架构](../../../../docs/architecture.md)。Provider 固定为宿主内置的 OpenAI 与 xAI;插件实现只依赖公开 SDK 合同。 -单元测试与文档构建不代替目标平台上的安装、授权及实际能力验证 +单元测试与文档构建不代替目标平台上的安装、配置及实际能力验证 diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index 5824a36b7..0a899bbfa 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -2,12 +2,11 @@ [返回 SDK](../README.md) · [清单与图标](manifest.md) -`contributes` 声明插件提供什么功能,`permissions` 声明插件需要访问哪些宿主资源。两者不是同一件事: -例如,管理页面声明 `management` 能力,需要调用模型时再声明 `models` 访问域 +`contributes` 声明插件提供的处理器。安装者完整信任插件,宿主回调不需要权限声明或阶段授权,参见[完整信任](manifest.md#完整信任) | 开发任务 | 入口 | | --- | --- | -| 组合处理器并声明权限 | [作者入口](#类型化作者入口) · [能力与方法](#能力与方法) · [访问域](#访问域) | +| 组合处理器 | [作者入口](#类型化作者入口) · [能力与方法](#能力与方法) | | 使用宿主资源 | [基础事实](#基础事实) · [额度刷新](#额度观测刷新) · [账号](#账号与凭据) · [自有资源](#自有资源与维护) · [Key 预算](#client-key-预算) · [模型](#key模型与模型调用) · [网络](#网络) | | 扩展请求链 | [中间件](#洋葱中间件) · [上游适配器](upstream-adapters.md) · [模型目录](#模型目录) · [重试](#重试决策) · [路由、调度与观察](#路由调度与观察) · [入口认证](#数据面入口认证) | | 管理与交互 | [状态、日志与迁移](#状态日志与迁移) · [管理页面](#管理页面公开入口与-cli) · [页面宿主桥](#页面宿主桥-v2) · [命令行](#命令行) | @@ -47,7 +46,7 @@ session.run(plugin).await?; ## 能力与方法 -Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能力: +Provider 固定为宿主内置的 OpenAI 与 xAI。当前宿主开放以下 11 类扩展能力;`middleware` 使用 v3,其余使用 v1: | 能力 | 类型化方法 | | --- | --- | @@ -57,44 +56,19 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | `model_catalog` | `PluginBuilder::model_catalog` / `methods::MODEL_CATALOG_REGISTER` | | `retry_policy` | `methods::RETRY_DECISION` | | `scheduler` | `methods::SCHEDULE_ACCOUNT` | -| `request_lifecycle`、`usage` | `methods::OBSERVE_REQUEST`,同一实例的终态订阅合并调用 | -| `web_socket_observer` | `methods::OBSERVE_WEBSOCKET`,线方法为 `websocket.response_event` | +| `observer` | `methods::OBSERVE` / `observer.observe`,按事件类型订阅 | | `frontend_authentication` | `methods::FRONTEND_IDENTIFIER`、`methods::FRONTEND_AUTHENTICATE` | | `management` | `PluginBuilder::management`;公开回调另用 `methods::MANAGEMENT_CALLBACK` | | `command_line` | `PluginBuilder::command_line` | | `maintenance` | `methods::RECONCILE`;无需功能绑定 | -## 访问域 - -安装时接受清单声明的访问域,不存在另一套逐方法、逐用途或资源 ID 白名单。宿主仍会检查父调用、阶段、 -期限、实例代次、资源归属,以及 Key、账号和 Provider 自身的业务规则 - -| 权限 | 开放的宿主能力 | -| --- | --- | -| `network` | 通过 `host.http.*` 使用宿主受管出站网络;仍受统一代理、超时、大小和流控规则约束 | -| `upstream_connections` | 仅在 `upstream` 阶段使用已选账号的受管 HTTP / WebSocket;宿主注入凭据和账号代理,不能读取原始凭据或重新选号 | -| `models` | 列出非秘密 Key、按所选 Key 查询模型,以及通过 `host.model.*` 调用模型;调用可能产生消耗 | -| `accounts` | 查询账号、读取原始凭据及创建或替换账号;写入仍经过 revision CAS、审计和发布事务 | -| `data` | 在管理/命令/维护阶段只读账号、Key 的最小基础信息及已有额度观测,不包含凭据、写入或预测 | -| `requests` | 参与请求/响应处理、路由、调度、观察及亲和查询 | -| `public_endpoints` | 提供无需登录即可访问的已声明静态资源或一次性票据回调 | -| `groups` | 创建本实例分组,允许将所有现有及未来新增账号加入或移出这些分组,保留其他分组关系 | -| `keys` | 创建仅绑定本实例分组的 Key,返回非秘密身份;不授予其他 Key 的修改或明文读取权限 | -| `key_budgets` | 在管理/命令/维护阶段查询全部 Client Key 的预算、修改日/周金额上限及重置用量,不读取密钥或修改其他 Key 配置 | -| `quota_observations` | 在管理/命令/维护阶段读取及刷新全部账号的额度观测,不暴露凭据或执行上游额度重置 | - -`host.log` 和本插件声明的 `host.state.*` 是基础设施,不需要额外 permission。公开调用阶段不开放任何 -宿主回调;权限也不能把一个父调用的句柄、流或上下文转移到另一个调用 - ## 宿主资源回调 -所有回调都通过当前 `TypedCall.host` 发起,并受父调用截止时间和取消信号约束 +所有回调都通过当前 `TypedCall.host` 发起,关联父调用及其取消信号。普通调用使用父调用剩余期限;已经建立的资源流按[会话生命周期](../README.md#帧与会话)回收,网络操作单独计时 ### 基础事实 -纯展示页面声明 `management` 和 `data` 即可,不需要请求处理 binding 或 `accounts` 权限。 -`data` 是独立的管理员授权域,只允许 `management`、`command_line`、`maintenance` 阶段使用;请求链、注册及公开回调均拒绝。 -它可读取全部账号与 Key 的下列最小事实,不继承或授予客户端 Key 的模型执行权 +基础事实接口提供账号分页、Key 分组绑定及额度观测。查询不进入模型执行链,也不触发计费;账号凭据使用[账号接口](#账号与凭据)读取 | SDK 方法 | 回调 | 查询与结果 | | --- | --- | --- | @@ -106,7 +80,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗口;已知字段按声明校验类型与必填性,查询拒绝未知字段。 插件须通过 `engines.codex-proxy-rs` 限定支持所用接口和字段的宿主版本 -`key_facts` 每次读取当前管理数据,返回 `client_key_id`、`enabled`、`group_ids`;不存在的 Key 沿用事实接口的 `rejected` 错误。它需要 `data` 权限,`keys`、`key_budgets` 和 `quota_observations` 权限不能替代 +`key_facts` 每次读取当前管理数据,返回 `client_key_id`、`enabled`、`group_ids`;不存在的 Key 沿用事实接口的 `rejected` 错误 `group_ids` 表示显式绑定,包含停用分组,不是最终可路由账号集合;空绑定也不代表单账号范围。通过 `account_facts` 关联账号时需完整遍历分页,结果包含停用账号。跨查询关联及同步策略由插件负责,这些调用不构成跨查询事务,管理员修改绑定后应重新检查。 账号仅返回 `account_id`、`provider_id`、`name`、`email`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带令牌或代理信息。 @@ -126,31 +100,25 @@ for account in page.accounts { } ``` -已有 `usage` 观察提供请求最终用量、时间和结算事实;该投递有界、不是历史补偿接口。 +`observer` 的 `request_completed` 事件提供请求最终用量、时间和结算事实;该投递有界、不是历史补偿接口。 本接口不提供 SQL、池汇总、健康分、额度预测或历史使用记录查询;插件结果保存在自身 `host.state.*` 中 ### 额度观测刷新 -`quota_observations` 授权访问所有当前及未来账号的额度观测。它与只读 `data`、含原始凭据的 `accounts` -分别授权;不会授予账号目录、凭据读取、账号修改、上游额度重置或 Key 预算操作权限。 -需要枚举账号时另行声明 `data`,已知账号 ID 时可直接查询或刷新 +- `call.host.quota_facts(query)` / `host.data.quota.get`:读取已有快照,不访问上游 +- `call.host.refresh_account_quota(query)` / `host.quota_observations.refresh`:请求 Provider 刷新观测 -- `call.host.quota_facts(query)` / `host.data.quota.get`:`data` 或 `quota_observations` 任一域可读取已有快照,不访问上游 -- `call.host.refresh_account_quota(query)` / `host.quota_observations.refresh`:仅 `quota_observations` 可请求 Provider 刷新观测 - -两个方法仅在 `management`、`command_line`、`maintenance` 阶段开放;均使用 `call::data::QuotaFactsQuery` -和 `QuotaFacts`,控制参数为 `{}`,输入输出在二进制 JSON 中。刷新复用原生 Provider 的凭据、代理、超时及 -观测落库规则,返回同一最小事实投影,不返回凭据、预测、个人资料或 Provider 原始响应。 +两个方法均使用 `call::data::QuotaFactsQuery` 和 `QuotaFacts`,控制参数为 `{}`,输入输出在二进制 JSON 中。刷新复用原生 Provider 的凭据、代理、超时及观测落库规则 刷新观测不会消费额度重置券,也不会自动重置任何 Key;Provider 仍可能依据真实上游响应更新账号可用性观测 接口不承诺每次得到不同的周期或样本,也不保证结果对应一个账号重置事件。不存在账号返回 `rejected`; Provider 不支持刷新(如 OpenAI URL + Key 账号)返回 `invalid_input`,其他失败沿用原生管理错误映射。 刷新受父调用期限和取消约束;结果未知不表示观测未落库。SDK 不自动重复调用,插件应控制刷新频率并处理失败, -不能把失败、空值或旧快照当作额度恢复。维护阶段只允许这条受控原生刷新,不因此获得任意网络访问权 +不能把失败、空值或旧快照当作额度恢复 ### 账号与凭据 -`accounts` 域开放 `host.auth.list/get_runtime/get/save`: +账号接口使用 `host.auth.list/get_runtime/get/save`: - `list` 可用 `provider_id` 过滤并分页,返回不含原始凭据的运行投影 - `get` 的原始凭据只放在二进制载荷中 @@ -166,8 +134,7 @@ Provider 不支持刷新(如 OpenAI URL + Key 账号)返回 `invalid_input` 每个实例串行执行,不同实例相互独立;调用限时 30 秒,失败后等待 5 秒重试。维护失败不回滚已经提交的资源, 下一次对账继续补齐。只读校验、准备候选与 CLI 帮助不会启动维护;停用、替换和宿主关闭时取消旧任务 -维护阶段允许日志、私有状态,以及已授权的 `data`、`groups`、`keys`、`key_budgets`、`quota_observations` 回调;不开放任意网络、凭据或模型执行。 -其他管理/命令入口也可使用下列资源方法: +维护与其他处理器共用以下资源方法: | SDK 方法 | 参数与行为 | | --- | --- | @@ -178,21 +145,18 @@ Provider 不支持刷新(如 OpenAI URL + Key 账号)返回 `invalid_input` 资源键使用 1~64 位小写字母、数字、`_`、`.`、`-`,首位为字母或数字,在实例和资源类型内唯一。 归属由宿主签发,插件不能指定其他实例。`ensure` 只在首次创建时使用属性,不覆盖管理员对名称、启用状态或限制的修改; 同名的管理员资源不会被接管,名称冲突会失败。账号已被删除时增量加入会忽略该 ID。 -无实际变化时不会写审计或递增配置版本。创建与归属登记、成员变更、授权复验均在同一事务完成 +无实际变化时不会写审计或递增配置版本。创建与归属登记、成员变更、实例版本复验均在同一事务完成 插件升级沿用实例资源;停用保留分组和 Key,其原生启用状态保持不变。删除实例解除归属,资源仍由管理员管理; 重新安装为新实例不会接管旧资源。管理员删除自有资源后,仍启用的维护逻辑可在下次对账重新创建。 Key 明文仍通过宿主管理面查看,插件模型调用使用返回的 Key ID 典型处理器先确保分组存在,再通过 `data` 分页查询账号并增量补齐成员,最后确保 Key 存在。 -安装 `groups` 域即授权纳入全部当前及未来账号;插件可按自己的配置筛选账号,但该筛选不构成宿主的权限边界 +插件可将当前及未来新增账号纳入分组,筛选和同步策略由插件决定 ### Client Key 预算 -`key_budgets` 是原生 Key 预算访问域,仅在 `management`、`command_line`、`maintenance` 阶段使用。 -接受该域即允许查询、设置金额上限及清零全部当前及未来 Client Key,包括管理员和其他插件创建的 Key; -插件配置中的 Key 筛选不构成宿主权限边界。`keys` 只负责插件自有 Key 的创建,不能替代这一预算管理授权。 -`key_budgets` 不授予模型执行、密钥读取、Key 创建、名称/分组/画像或并发/RPM 配置修改权限 +预算接口可查询、设置日/周金额上限及清零全部 Client Key 的当前周期用量,包括管理员和其他插件创建的 Key 类型位于 `call::key_budgets`;下列预算方法控制参数为 `{}`,输入输出为二进制 JSON: @@ -210,78 +174,203 @@ Key 明文仍通过宿主管理面查看,插件模型调用使用返回的 Key 时间为 UTC Unix 毫秒,`null` 表示尚未使用或窗口已过期。读取不触发准入、开启窗口或清零,停用的 Key 仍可管理 上限更新仅写入提供的日/周金额;省略或 `null` 的项保持不变。它保留已用金额、窗口到期时间、费用历史及其他 Key 配置。 -写入复用 Key 行锁,与结算串行;实际变化时授权、修改、配置 revision 和审计在同一事务提交,并通知原生配置发布。 +写入复用 Key 行锁,与结算串行;实际变化时实例版本复验、修改、配置 revision 和审计在同一事务提交,并通知原生配置发布。 相同值再次赋值不产生新 revision 或审计;并发更新按事务顺序生效,同一字段由后提交的值覆盖,接口不提供调用去重或比较交换。 将非零上限降低到已用金额及以下会拒绝后续准入,提高限额可恢复准入,在途请求仍按原生规则完成和结算 重置只清零所选周期,保留额度上限、密钥、窗口到期时间和历史费用事件;未使用的 Key 不因此开启窗口。 费用按完成时间归属,重置前完成但延迟落盘的费用不会重新扣入已重置周期,重置后完成的在途请求仍正常扣额。 -授权复验、清零和审计在同一事务提交,不推进配置 revision。每次调用均执行新重置,可能清掉两次调用之间的新消费 +实例版本复验、清零和审计在同一事务提交,不推进配置 revision。每次调用均执行新重置,可能清掉两次调用之间的新消费 SDK 不自动重试。超时或断连不能证明写入未提交;重试上限赋值也可能覆盖期间其他调用的修改。 -不存在的 Key 返回 `rejected`,无权限或阶段不符返回 `permission_denied`;写入事务复验发现实例停用、版本或授权变化时返回 `conflict`, +不存在的 Key 返回 `rejected`;写入事务复验发现实例停用或版本变化时返回 `conflict`, 非法输入返回 `invalid_input`。账号关联、预算分配和重置触发由插件决定,宿主不自动串联上述接口 ### Key、模型与模型调用 -`models` 域开放: +模型调用方法: - `host.keys.list`:只返回 `{id, name, enabled}`,不返回 Key 明文、前缀或隐藏 scope - `host.models.list`:以显式 `client_key_id` 查询该 Key 当前可见的模型目录 - `host.model.execute[_stream]`:交给 Core 的路由、准入、租约、重试、账本和计费链路执行 - `host.model.stream_read/close`:读取或关闭当前父调用创建的有界流 -`management`、`command_line`、`authentication` 和 `observation` 阶段调用模型时,在 -`ModelExecuteRequest.client_key_id` 中显式选择执行 Key;`request`、`attempt`、`routing` 和 -`scheduling` 阶段将该字段留空并继承父请求身份,不能切换 Key。Key 的启用状态、模型规则、账号组、并发和预算 -始终使用调用时的当前事实。 -插件不能读取 Key 明文,也不能用一个 Key 查询结果替另一个 Key 执行 +`ModelExecuteRequest.client_key_id` 可显式选择执行 Key,不与触发插件的父请求 Key 求交。省略时继承现有模型父请求身份;没有模型父请求的调用必须提供 Key。所选 Key 的启用状态、模型规则、账号组、并发和预算继续由 Core 执行 -模型事件使用 `call::model::ExecutionEvent` 的 `GPE1` 封套,将 canonical 事实与原始 wire 分段编码。 +模型事件使用 `call::model::ExecutionEvent` 的 `GPE2` 封套,将 canonical 事实、宿主事实和原始 wire 分段编码。`host` 包含费用及定价明细、完整上游观测、会话状态和改写来源;当前 wire 与原始 wire 都保留二进制字节。读取或回传快照不触发第二次计费、续接提交或发送状态更新。 非流式模型结果通过 `ModelEventBatch` 的 `HME1` 有界二进制批次返回;流式句柄只在创建它的父调用内有效。 -发起回调的插件实例在子调用中跳过,Core 还会限制递归深度、总数和并发,避免插件调用自身形成环 +发起回调的插件实例在子调用中跳过,Core 在单次执行的子调用图内限制递归深度、总数和并发,避免插件调用自身形成环。 +显式 Key 的身份快照随父调用保留,每个模型执行独立计时并建立调用图;父调用结束会取消尚未完成的执行,连接空闲不消耗后续执行的期限 -`host.affinity.lookup` 属于 `requests` 域,只查询 Provider 已持久化的真实亲和键。命中只是账号偏好, +`host.affinity.lookup` 查询 Provider 已持久化的真实亲和键。命中只是账号偏好, 后续执行仍重新检查当前 Key 规则、账号资格和租约 ### 网络 `HostClient::http(request, body)` 返回响应头与 `HostHttpBody`。正文用 `read()` 按需读取, 用 `collect(maximum_bytes)` 有界收集,提前结束用 `close()`;EOF 与重复关闭不再调用宿主。 -父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。账号上游使用同一正文对象,权限与目标见[受管上游](upstream-adapters.md) +父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。取消一次 `read()` 等待后,再次读取会继续同一次操作,不丢弃已经返回的分块。 +账号上游使用同一正文对象,使用方式见[受管上游](upstream-adapters.md) -`network` 域通过 `host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。URL、header 和正文可能含敏感数据, -正文使用独立二进制载荷。宿主统一施加代理、超时、帧与流限制;插件不能绕过宿主端口取得额外调用身份。 -地址被拒绝返回 `permission_denied`,期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。 -这些 HTTP 业务错误的 `message` 是宿主生成的安全分类提示,不包含目标 URL、凭据或上游错误正文, -插件可以在相应操作中展示;协议故障不应直接展示内部消息。Fake-IP 环境按[部署排障](../../../../../deploy/README.md#插件取文与-fake-ip-dns) -调整 DNS,不通过放宽地址范围解决 +`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。 +普通调用使用父调用剩余期限;已建立的受管 HTTP 流或 WebSocket 会话按每次网络操作计时,仍受父资源取消约束 + +期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。错误消息提供分类提示,响应正文仍由插件读取;协议故障不应直接展示内部消息 ## 请求扩展 ### 洋葱中间件 -`middleware` 的 `request` mount 在逻辑请求外层执行一次,`attempt` mount 在每次选定 Provider 和账号后重建。 -作者必须在清单中显式选择一个或两个阶段。`PluginBuilder::middleware` 接收 `MiddlewareCall`,其中 -`next.run(request)` 是 single-use continuation;响应按相反顺序穿过中间件 +`middleware.version` 为 `3`,所有挂载使用同一个 `ctx + next` 组合器。清单显式选择处理边界,绑定决定顺序; +不按 URL、消息类型或每个内部步骤增加处理器。仅处理一个边界可直接声明其调用类型;跨边界插件使用 `MiddlewareCall` +在一个处理器内匹配类型化视图,不感兴趣的调用交给 `call.forward().await` -请求正文和响应帧受 `requests` 域保护。没有正文时仍要区分“保持原正文”和“替换为空”:默认使用 -`MiddlewareRequestBody::Preserve`,只有 `replace_body(Vec::new())` 才表示清空。Header 修改采用增量 -remove/append,保留合法多值。`MiddlewareBody::map_frames` 复用会话的流控和唯一终态,不建立插件全局流表 +| 挂载 | SDK 视图 | 默认终端 | +| --- | --- | --- | +| `http` | `HttpCall` | 路由匹配、认证、正文解析与既有 HTTP handler | +| `websocket` | `WebSocketCall` | 入站业务消息分派/出站连接写入 | +| `service` | `ServiceCall` / `TypedServiceCall` | 已登记的公开业务服务 | +| `request` | `RequestCall` | 一次逻辑模型请求 | +| `attempt` | `RequestCall` | 本次已选 Provider 和账号的执行 | + +`PluginBuilder::middleware` 与 `MiddlewarePlugin::new` 共用 `MiddlewareInput` / `MiddlewareOutput`。 +`next.run(input)` 消费自身,最多执行一次;结果按相反顺序经过外层。重试或独立子请求通过宿主执行服务发起 + +HTTP 总入口统一包裹原生路由,`dispatch_http` 复用同一路由与组合能力;无需为每个 HTTP 接口登记新的挂载点。 +`service` 用于不经过 HTTP 的类型化主动调用,具体公开范围见下节 + +#### 公开服务 + +服务合同位于 `call::services`,`Operation` 关联稳定名称、输入和输出类型。 +`HostClient::service::(input)` 主动调用服务;`ServiceCall::is::()` 匹配操作, +`into_typed::()` 取得可修改输入、单次 `next`、父调用标识及宿主客户端。 +返回 `ServiceResponse::from_result::(result)` 可以改写结果、短路或恢复业务错误; +不感兴趣的操作调用 `forward()`。公开值完整传递,类型校验不承担字段权限控制 + +当前登记 `settings` 的 11 个操作:`Load`、`Replace`、`ApiKeyExists`、`RegenerateApiKey`、`DeleteApiKey`、 +`Pricing`、`PreviewPricingSync`、`Sync`、`Update`、`ClientProfileOptions`、`PreviewClientProfile`。 +插件回调和 CLI 经注册表主动调用时统一进入一次 `service` 组合;网关原生设置接口由 HTTP 总入口包裹。 +各入口共用同一业务实现,业务方法内部调用不重复进入 `service` 组合。 +其他管理接口复用既有 HTTP 路由,不要求按内部方法另建服务目录;既有账号、Key 等资源回调不自动进入 `service` 组合 + +`Load` 返回完整运行设置与 `config_revision`。`ReplaceRuntimeSettings::from(settings)` 保留该版本并构造更新命令; +`Replace` 接收 `(MutationContext, ReplaceRuntimeSettings)`,通过已有校验、事务、审计和快照发布保存设置。 +版本过期返回 `ServiceError { kind: "conflict", message }`。读取结果的普通改写不会持久化,也不会回滚已经提交的事务 + +服务错误保留分类与完整业务文案,子调用继承冻结发布计划及取消信号。 +插件显式再次调用服务时继承发起实例链并跳过相同实例,防止无限递归;无 service 绑定时直接分派业务实现,仍保留空计划快照、父子关联和取消信号。 +原生业务方法保留类型化调用,不为服务组合增加 JSON 编解码 + +#### HTTP 入口 + +`HttpCall.request` 提供 method、URI(含 query)、HTTP version、完整多值 headers、`settings`、`timeout_ms` 和 `HttpBody`。 +调用发生在路由匹配之前,管理接口、模型接口和未知路径都可参与。`settings` 是同一请求快照中的宿主运行设置, +插件改写后用于后续认证、版本检查、正文解压及执行;不新增设置业务的中间件入口。`timeout_ms` 使用宿主 HTTP 超时基线, +插件可改写或用 `None` 清除;终端按有效值执行,不恢复宿主默认值。宿主快照不可用时 `settings` 为 `null` + +`HttpCall.settings_sources` 返回 `config_revision`、宿主 `host` 基线、运行设置的 `overrides`、Key 作用域的 `execution` 与 `http_timeout`。 +每条改写记录含 `instance_id`、`order` 和 `value`;来源由宿主记录,修改查询结果不会写入设置。 +宿主比较提交前后的值,仅记录实际改变的设置项;同值赋值不新增改写记录。每个顶层设置项整体替换,`false`、`0`、 +空映射及合同允许的 `null` 都是值,不进行隐式深合并。改写只属于当前调用,不修改持久设置 + +`HttpBody::read` 按需读取数据帧或 trailers;`map_frames` 包装上传或响应。透传只搬运句柄,保留原始分块、trailers、 +request/response extensions 与连接升级状态。上传转换使用单帧有界管道;下游可在上传完成前返回响应,剩余生产随响应正文驱动, +提前拒绝不要求继续读完整个上传。每次调用最多保留 16 个活动上传管道,写入 EOF、显式关闭或消费端退出即归还名额。 +读取、发送和生产都随父调用取消,不启动脱离调用的生产任务 + +取消一次宿主正文读取的等待不会丢帧,再次读取继续原操作;有未完成读取时转交 `HttpBody`,SDK 按输出背压保留该读取并转发剩余正文。读取量遵守宿主 Credit 窗口,已经在途的较大块会拆分保留 + +`HttpResponse::new` 创建短路结果。`next` 返回响应后可改写状态、headers 或包装正文;返回响应头不代表流已结束。 +`host.middleware.next` 与 `host.http.body_*` 复用当前调用的 HTTP 资源池;已消费的 next 或正文句柄不能再消费一次。 +读取 EOF 或转交正文会消费读取句柄;SDK 在 EOF 后继续读取返回 `None`,重复关闭不发起 RPC。 +`body.close().await` 只关闭正文,仍可替换正文并返回原响应;响应部件保留到返回响应或调用资源池释放。 +插件进程已经启动本次 HTTP 调用后,故障不会自动重放下游;无法启动且绑定为 `delegate` 时才直接委托 + +`HostClient::dispatch_http(HttpRequest)` 主动调用宿主相对路径,复用相同的路由、HTTP 洋葱链与惰性正文资源,不通过网络回环。 +`HttpCall.call_id` 和 `parent_call_id` 表达父子关系,`context.request_id` 保持同一请求关联;子调用沿已冻结的计划运行并跳过所有发起祖先实例,最多嵌套 4 层。 +主动管理请求使用宿主签发的插件身份和系统审计,不依赖父请求的管理员 Cookie;模型请求仍需提供用于模型范围与结算的 Key。普通 HTTP 请求头不能声明插件身份。 +子调用继承当前配置快照与显式改写,`HttpRequest.settings = null` 表示沿用;提供对象时按设置项比较并替换。 +切换 Key 后重新解析目标 Key 的默认画像和限额,父 Key 的默认值不会带入;运行设置改写继续生效,Fast、Key 限额和模型超时的改写只对原 Key 生效。同级调用的修改互不回写 +主动 HTTP 分派计入模型父请求的副作用水位,避免下游失败后透明重放子调用。父调用取消会终止等待中的子请求与响应读取;转交正文不因父 RPC End 提前取消。消费完成会释放原正文,提前结束时调用 `body.close().await` 释放正文句柄 + +#### 自定义 WebSocket 会话 + +HTTP 中间件可调用 `call.upgrade(protocols, handler).await` 接管当前请求,返回仍经过外层 HTTP 中间件。 +`protocols` 按插件给出的顺序与客户端协商子协议;握手不合法或当前请求不支持升级时明确失败。 +默认路由不会再执行,路径选择、认证、协议解析及每条消息产生哪些模型调用由插件处理 + +`handler` 接收 `WebSocketSession`,使用 `receive().await` 读取完整消息,复制 `sender` 后可在接收等待期间独立发送。 +在 `select!` 中取消一次 `receive` 等待不会丢弃消息,下一次等待继续同一次接收。 +`close(code, reason).await` 发送关闭帧;处理函数返回、失败、插件退出或客户端断开时,宿主回收连接与调用资源。 +会话的收发也经过已冻结的 `websocket` 消息组合,主动写入与接收不共享跨等待的独占锁 + +握手受初始调用期限约束。成功返回响应头后,会话持有同一发布代次及父调用,直至连接或资源终结; +101 正文完成不会取消会话。外层中间件替换响应会丢弃未提交的会话,改变已选择的升级状态码会被判为无效响应。 +一个会话可通过捕获 `call.host` 发起多个独立模型执行;模型关联、用量和费用仍归各自执行所有 + +#### 下游 WebSocket 消息 + +`WebSocketCall` 提供连接 ID、握手完整 headers、`direction`、原始 `message`、单次 `next`、`sender` 和取消信号。 +文本、二进制、Ping、Pong 和 Close 均携带原始正文;消息类型不由宿主预先按第三方协议筛选。 +`WebSocketPayload` 按需读取,透传不读取、不复制正文;插件需要完整内容时自行 `collect().await`。 +不再使用的句柄通过 `payload.close().await` 释放;读取到 EOF 或转交消息也会消费资源。 +取消一次读取等待后可继续读取;未完成的读取须先继续消费或关闭,直接转交该消息会报错,避免静默丢弃在途正文。 +单次调用最多保留 16 个未消费的消息正文,达到容量后需先消费或关闭已有句柄 + +`next.run(message)` 返回可继续修改的 `Option`;返回 `None` 丢弃当前消息。 +`sender.send(message).await` 是当前连接的直接写入能力,等待实际 transport 完成,不递归进入消息中间件。 +入站插件在响应进行中仍可处理未知控制消息并主动回写;是否转发到上游及其协议语义由插件决定。 +消息正文可改写,实际写入、错误和取消事实由连接 owner 记录,丢弃消息不会记作写入成功 + +Ping 的自动 Pong 与关闭握手属于底层 WebSocket 协议事实;插件观察消息时,transport 可能已经产生应答。 +重写消息不能撤销已经发生的发送。文本必须是 UTF-8,控制帧长度和关闭格式仍遵守 WebSocket 协议。 +每条消息组合有独立 RPC 期限;自定义会话持有一个受管流式调用。连接结束会取消仍在处理的消息与发送 + +#### 模型请求与 attempt + +`RequestCall.request.head` 包含完整 Client Key 与分组归属。OpenAI 在进入 attempt 前应用宿主档位设置,插件显式改写后不再恢复默认值。 +OpenAI 与 xAI 的上游 headers 先由账号和画像生成,插件同名字段覆盖基线,同名多值按插件顺序保留;不存在按认证、Cookie 或会话名称划分的保护列表。 +实际账号租约、凭据版本与计费归属仍记录真实选中事实,不随插件修改 header 名称而伪造 + +request 阶段的 `request.head.settings` 是本次调用的有效设置对象,包含 `runtime`、`disable_fast`、`client_limits` 和 `timeout_ms`。 +`runtime` 使用宿主快照的设置字段,画像已合并 Client Key 设置;可以修改映射、画像、调度、位置及计价等执行参数。 +SDK 仅在对象改变时提交完整替换,`false`、`0`、空映射和合同允许的 `null` 不表示跳过;不做隐式深合并。 +多个插件按入站顺序读取前层改写后的值,终端在执行前从同一配置代次重算,改写不发布全局配置。 +`timeout_ms` 从本次模型请求开始计时,改写不会清零已用时间;HTTP 处理期限与 RPC 操作期限仍各自生效 + +此视图在客户端认证及初始协议解码之后出现:不会追回已经完成的版本准入或入口解压校验。 +Responses 终端重新解码插件正文时读取改写后的正文上限;attempt 阶段的 `settings` 为 `null`,已经完成的准入不能经该入口重做。 +更早的改写使用上述 HTTP 请求的 `settings`。`request.head.settings_sources` 使用相同的来源结构,`execution.input` 表示进入当前 Key 作用域时的有效值。 +原生 Responses WebSocket 每轮请求读取当前宿主快照,重新认证并解析 Key 策略,同时保留握手阶段的显式改写;已开始的执行保持自己的冻结值 + +`middleware.version` 必须为 `3`。插件可读取和改写完整请求、响应 headers、正文与响应帧,不要求 +`requests` 权限,也不按字段名称过滤认证、Cookie、会话或连接头。安装者承担插件读取与修改这些数据的风险。 +类型、HTTP 格式、大小及资源生命周期仍需符合合同 + +默认使用 `MiddlewareRequestBody::Preserve` 保持正文;`replace_body(Vec::new())` 明确清空。 +Header 修改采用增量 remove/append,保留合法多值。`MiddlewareBody::map_frames` 复用会话的流控和唯一终态 + +`response.metadata` 提供 Provider、实际模型、账号、上游请求 ID 和账号选择观测。 +`response.body.with_facts()?.inspect_frames(...)` 或 `.map_frames(...)` 通过 `frame.facts` 读取完整事件快照;纯事实帧可没有正文。 +快照按需读取,未开启读取或未消费正文时不复制事件载荷;来源帧完成后对应句柄失效。 +修改快照不会改变宿主确认的原执行事实,响应改写仍通过 headers、正文和受管调用完成 + +`next` 和模型子调用返回的 `PluginFault` 保留具体消息、发送状态、HTTP 状态及 `details`。 +域错误详情包含分类、上游 type / code、重试提示和诊断;Provider 错误还包含请求标识、原始错误、HTTP 响应字节与多值 headers、连接及续接恢复事实。 +公开服务的 `ServiceError.details` 保留跨进程错误详情。错误快照供插件读取,宿主继续持有原错误处理重试、发送与账本事实 **不改写就 1:1 保留**:同一网关处理边界上,默认 `next.run(request)` 保留原始正文、未修改的 header 和响应流字节及顺序。 -只解析 `request.body` 不会触发替换;SDK 比较原投影,宿主持有的隐藏 header 不会被脱敏视图覆盖。 +只解析 `request.body` 不会触发替换;SDK 比较完整原始输入,只发送实际修改。 只观察响应可使用 `response.body.inspect_frames(|frame| { /* 读取,不修改 */ })?`,保留来源信封、背压和取消语义。 这一保证比较插件前后的同一边界,Provider 自身的协议适配、两次调用的随机值和网络分段不在比较范围内 -需要声明功能转换时,在清单选择 `middleware.version=2`,普通 v1 插件仍可原样运行。 -`request.declare_capabilities(CapabilityDeclaration { handled, required })` 随单次 `next` 发出,必须同时显式替换正文且具有 `requests` 权限。 +`request.declare_capabilities(CapabilityDeclaration { handled, required })` 随单次 `next` 发出,必须同时显式替换正文。 当前仅允许 OpenAI 生成请求的 request 阶段,HTTP 与 WebSocket 共用规则;不扩展端点允许的协议或流模式。 `handled` 只允许列举改写前确实存在、改写后已从实际请求移除的 `tools`、`vision`、`reasoning`、`json_schema`, 插件须负责对应的提示词转换及普通响应、错误、流式响应还原。`required` 为额外上游需求,不能覆盖正文推导出的要求。 -重复项、同一项同时出现在两组、空声明、v1 使用声明、attempt 阶段使用声明均会拒绝。 +重复项、同一项同时出现在两组、空声明、attempt 阶段使用声明均会拒绝。 原始语义中未由插件承担的功能继续约束路由;实际正文重新加入某功能时,该需求仍生效。 -原生续接不能由声明豁免,attempt 阶段的既有冻结与保护字段检查继续生效 +原生续接不能由声明豁免,attempt 已取得的账号租约与真实发送事实仍由原 owner 持有 ```rust,ignore use gateway_plugin_sdk::call::middleware::{CapabilityDeclaration, RequestFeature}; @@ -295,7 +384,7 @@ restore_and_validate_response(response).await ``` 仅实现中间件时也可使用轻量的 `MiddlewarePlugin`;需要与管理或其他方法组合时使用 -`PluginBuilder`,两者复用相同的 `MiddlewareCall` 和 `MiddlewareResponse` +`PluginBuilder`,两者复用相同的类型化调用与结果合同 ### 扩展的组合边界 @@ -307,7 +396,7 @@ restore_and_validate_response(response).await | 请求头/正文改写、提前拒绝、响应头处理 | `middleware` 包裹一次 `next.run(request)` | | 响应逐帧转换、观察 | 返回惰性 `map_frames` / `inspect_frames`,不能在 `next` 返回时就把流视为结束 | | 新上游路径、协议编码、错误及用量解析 | `upstream_adapter` 终端,复用已选账号和原生结算 | -| 目标限制、凭据注入、网络发送状态 | 宿主网络外层,普通 HTTP 与账号 HTTP 共用发送和正文资源实现 | +| 账号代理、默认凭据注入、网络发送状态 | 宿主网络外层,插件可覆盖默认请求头,普通 HTTP 与账号 HTTP 共用发送和正文资源实现 | | 路由、调度、重试决策 | 各自的决策端口,Core 保持最终裁决和预算 | | RPC Credit、取消、流关闭、WebSocket 续接 | 会话及资源生命周期,不作为可重排的中间件 | | 终态观察、管理与维护 | 原有观察/管理/维护入口,不嵌入请求 `next` 链 | @@ -356,41 +445,58 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? 按 binding 顺序、插件 ID、实例 ID 确定委托链,首个合法非委托结果生效。整条链最多等待 2 秒,并受请求剩余期限约束。 退出、超时、未知字段及不允许的动作委托后续处理,最终回到宿主决定;故障不改写原始上游错误。 -本阶段宿主只开放日志与插件私有状态回调,不允许额外网络、模型调用或账号访问。middleware 的 `next` 仍最多调用一次 +middleware 的 `next` 最多调用一次,重试与额外模型执行通过独立调用创建 ### 路由、调度与观察 `policy.route_model` 和 `policy.schedule_account` 只提出决定,宿主随后复核 Key 规则、模型能力、账号资格和租约。 -`policy.observe_request` 接收一次最终请求事实;`request_lifecycle` 提供终态,`usage` 提供最终标准化用量、费用和耗时。 -缺失值表示宿主没有该事实,不等于零,也不能由插件重新计价 +`observer` v1 通过 `methods::OBSERVE` 接收 `call::observation::Event`,固定绑定 `observation/observe`。 +每条绑定以 `event` 选择 `request_completed` 或 `websocket_response`,可分别配置请求范围;同一实例每种事件至多一条绑定,两类绑定使用相同 `order`。 +事件选择只决定是否投递,不裁剪字段 -`websocket.response_event` 只读观察真实上游 WS 事件,元数据与原始帧分开传输。事件序号在请求内单调递增; -有界队列丢弃可能产生间隔。观察超时、过载或插件失败不改变业务响应,插件也不能修改 WS 帧 +- `Event::RequestCompleted` 包含一次最终请求事实,`terminal` 与 `usage` 始终存在;用量、费用与耗时的缺失值表示宿主没有该事实,不等于零 +- `Event::WebSocketResponse` 观察实际上游 WS 事件,原始帧放在 `TypedCall.payload`,元数据放在 `request`;序号在请求内单调递增,有界队列丢弃可能产生间隔 -观察计划在请求开始时冻结,最终事件最多派发一次。`Handshake`、`Message`、`Frame` 和 `PluginFault` 的 -调试输出会隐藏配置、载荷与错误正文;插件自己的日志同样不能输出凭据或完整请求 +两类事件共用 `observer.observe` 方法及同一处理器。观察计划在请求开始时冻结;完成事件最多派发一次,WebSocket 事件按请求保序;完成通知不等待 WebSocket 观察队列排空。 +观察超时、过载或插件失败不改变业务响应,返回值不用于修改帧或重新计价。需要改写响应时使用洋葱中间件 + +```rust,ignore +use gateway_plugin_sdk::call::observation::Event; +let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? + .on(methods::OBSERVE, |call| async move { + match call.request { + Event::RequestCompleted(request) => record_completion(&request), + Event::WebSocketResponse(response) => record_frame(&response, &call.payload), + } + Ok(TypedReply::new(Empty {})) + })? + .build()?; +``` + +`Handshake`、`Message`、`Frame` 和 `PluginFault` 的调试输出隐藏配置、载荷与错误正文;插件自己的日志同样不能输出凭据或完整请求 ## 状态、日志与迁移 清单 `state[]` 声明命名空间、`schemaVersion`、JSON schema、记录与字节配额。运行时用 `host.state.get/put/delete` 访问;创建要求键不存在,更新和删除必须提供精确版本。状态始终绑定当前 -插件实例、制品和 schema,不能直接读写宿主数据库 +插件实例、制品和 schema;该接口操作插件状态,不代替宿主业务事务 不兼容升级由宿主停用并排空旧会话,再调用 `plugin.state.migrate` 分批迁移到暂存状态。插件逐项返回 keep、replace 或 delete;全部完成后宿主才原子提交新配置与状态,失败时丢弃暂存结果 `host.log` 接收固定事件名、级别和有界字段。宿主脱敏并限制速率;`recorded: true` 只表示已交给有界日志任务。 -日志和私有状态无需 permission,但注册、配置与公开管理阶段仍受各自的回调生命周期限制 +日志、状态与其他宿主回调都属于当前父调用,父调用结束后不能继续使用其资源 ## 管理页面、公开入口与 CLI `PluginBuilder::management` 同时冻结 `ManagementRegistration` 并绑定 `management.handle`。请求元数据在控制参数, -原始 HTTP 正文在二进制载荷;响应同样分离。宿主不转交管理 Cookie、Authorization 或任意 header,插件也不能 -返回任意响应 header。账号操作通过 `accounts` 域调用宿主,资源由调用参数选择,不在路由注册中固定 Provider +原始 HTTP 正文在二进制载荷;响应同样分离。`ManagementRequest.headers` 完整保留原始请求头, +`ManagementResponse.headers` 可显式覆盖宿主默认响应头;两者使用 `MiddlewareHeader`,保留重复项与非 UTF-8 值。 +宿主只校验 HTTP 语法,不按 Cookie、Authorization 等字段名称裁剪。账号操作通过宿主账号接口执行, +资源由调用参数选择,不在路由注册中固定 Provider 页面运行在隔离 iframe 中,静态资源必须同时出现在清单 `resources` 和管理注册中。标记为公开的资源及一次性 -票据回调需要 `public_endpoints`;`management.callback` 使用 `public_management` 阶段,并且不能调用账号、 -网络、模型、状态或日志端口 +票据回调按注册内容开放;`management.callback` 使用 `public_management` 阶段,与其他处理器共用宿主回调 宿主自动注入尺寸监听与上报脚本,为 `body` 设置扣除页面头部和外侧留白后的最小高度,整页滚动由管理端承接。 插件无需调用 resize、发送尺寸消息或安装桥接依赖;Vue 等框架的根容器需要延续最小高度时,使用普通 CSS `min-height: inherit`。 @@ -440,17 +546,18 @@ for await (const chunk of response.body!) { ``` `clientKeyId` 是管理端 Key 标识,不是 Key 原文;页面可通过自己的已注册管理路由调用 `host.keys.list` 和 -`host.models.list` 获取 Key 候选及其可见模型,但桥不会读取或返回秘密。该请求进入普通 Responses 数据面, +`host.models.list` 获取 Key 候选及其可见模型;该便捷桥用 ID 选择推理身份,不需要页面提供 Key 原文。该请求进入普通 Responses 数据面, 因此当前实例自己的 middleware、router、scheduler 和 observer 也按常规计划执行;它不是插件执行期间的嵌套 `host.model` 回调。正文最多 8 MiB,每页最多 4 个并发模型 请求,总时限 10 分钟;响应以不超过 64 KiB 的单块 pull/ack 传输。页面 30 秒不继续消费响应、调用 `AbortController.abort()` / `ReadableStream.cancel()`,或页面被切换、停用、换版时,宿主都会取消请求。桥不接受 -任意 URL、header 或 Key 原文;只转交响应状态、安全 header 以及原生 JSON / SSE 正文 +任意 URL、header 或 Key 原文;转交响应状态、浏览器 `Headers` 可读取的完整响应头以及原生 JSON / SSE 正文。 +这些页面辅助方法不限制插件进程在统一 HTTP 中间件中读取、改写请求,或通过 `dispatch_http` 调用其他宿主接口 ### 命令行 `PluginBuilder::command_line` 冻结命令帮助并绑定执行函数。注册和执行的 JSON 都位于二进制载荷,控制信封为 `{}`。 -命令若返回待保存账号,需要 `accounts` 域;只有退出码为零时宿主才按顺序执行各自独立的 CAS 提交。 +命令返回待保存账号时,只有退出码为零时宿主才按顺序执行各自独立的 CAS 提交。 模型命令通过 `host.keys.list` 展示非秘密 Key,再显式选择 Key 查询模型和执行,不依赖安装时预绑定 ## 数据面入口认证 diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index 16634cbcc..bf61826e1 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -11,14 +11,13 @@ | `displayName` / `author` | 人类可读名称 / 可选作者文字,不参与身份与授权 | | `version` / `engines` | 插件业务版本 / 宿主版本范围;安装包不能使用全版本通配 `*` | | `contributes` | 插件提供的扩展能力;作者可省略可推导字段 | -| `permissions` | 需要访问的宿主资源域;安装即接受该版本声明的域 | | `configurationSchema` / `secretFields` | 普通设置的 schema / 单独保存的敏感字段 | | `main` / `resources` | 包内可执行文件 / 资源路径与 MIME 类型 | | `state` | [私有状态](capabilities.md#状态日志与迁移)的命名空间、schema 与配额 | | `package` | CLI 生成的协议版本、单一目标平台与文件摘要,不需作者手写 | 作者清单不能包含 `package`。使用 `Manifest::from_author_slice()` 校验并规范化;直接反序列化的 `Manifest` -代表完整合同,不会隐式补全作者字段。打包后,`main` 和所有资源都必须进入 `package.files`;宿主用 +不会执行作者清单的 ID 与固定阶段推导,声明的可选字段仍使用其默认值。打包后,`main` 和所有资源都必须进入 `package.files`;宿主用 `package_for(host, os, architecture)` 检查版本与平台。运行模式仅为 `trustedProcess`:插件拥有与宿主 相同的系统身份,**不是进程沙箱** @@ -32,11 +31,17 @@ | `contributes..version` / `capabilities[].versions` | 指定扩展能力的行为合同 | | 宿主声明的 `schema_version` | 宿主兼容声明自身的格式 | -`capabilities` 使用扩展能力标识,`permissions` 使用资源访问域标识;Provider、模型和账号 ID 不属于这两个集合 +`capabilities` 使用扩展能力标识;Provider、模型和账号 ID 不属于这个集合 + +当前清单、进程协议和宿主兼容声明的格式版本均为 `2`。清单不接受 `permissions` 字段;使用其他格式版本的包须用匹配的 SDK 和 CLI 重新构建 + +安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本; +数据库元数据允许的字段增减见[持久化规则](../../../../../docs/architecture.md#发布与执行边界),不改变清单或 RPC 合同。 +插件依赖新增接口或字段时,应通过 `engines.codex-proxy-rs` 声明所需宿主版本;字段语义不兼容时不能只修改版本号绕过校验 ## 扩展项简写 -普通作者声明可以省略 `id`、`version` 和固定阶段: +作者声明可以省略 `id` 和固定阶段;默认版本为 `1`,中间件必须显式选择版本 `3`: ```json { @@ -44,6 +49,7 @@ "management": {}, "command_line": {}, "middleware": { + "version": 3, "stages": ["request"], "inputFormats": ["openai"], "outputFormats": ["openai"] @@ -52,27 +58,28 @@ } ``` -默认扩展项 ID 为 `..`,版本默认为 `1`;使用能力需求声明的 `middleware` 选择 `2`。除 `middleware` 外,阶段由 +默认扩展项 ID 为 `..`。宿主只接受 middleware v3;使用 v1/v2 的插件须更新 SDK、处理器及清单后重新打包。除 `middleware` 外,阶段由 capability 固定并由工具生成: | 阶段 | 能力 | | --- | --- | | `authentication` / `routing` / `scheduling` | `frontend_authentication` / `model_router` / `scheduler` | | `registration` / `retry` | `model_catalog` / `retry_policy` | -| `observation` | `request_lifecycle`、`web_socket_observer`、`usage` | +| `observation` | `observer` | +| `upstream` | `upstream_adapter` | | `management` / `command_line` | `management` / `command_line` | | `maintenance` | `maintenance` | -`middleware` 必须显式选择 `request`、`attempt` 或两者;协议格式等真实业务选择也不能省略。 +`observer` 使用一个处理器接收完成与上游 WebSocket 事件,实例绑定按 `event` 选择订阅类型;具体合同见[观察事件](capabilities.md#路由调度与观察)。宿主仅接受 `observer` 声明;使用 `request_lifecycle`、`usage` 或 `web_socket_observer` 声明的插件须更新清单、处理器与绑定后重新打包 + +`middleware` 必须从 `http`、`websocket`、`service`、`request`、`attempt` 中显式选择挂载;同一处理器可覆盖多个边界,协议格式等真实业务选择也不能省略。 安装清单若携带不同的固定阶段会被拒绝,而不是在加载时静默改写 -## 权限 +## 完整信任 -`permissions` 按[访问域](capabilities.md#访问域)声明所需宿主资源,安装时统一接受该版本的声明 +安装并启用插件意味着信任其全部代码和行为。插件与宿主使用相同的系统身份,可以访问数据、凭据和网络;安全由安装者承担,宿主不提供插件安全沙箱 -不填写逐方法、用途、Key、账号或 Provider 白名单;调用仍受阶段、父调用、Key 规则、账号 revision、资源归属和流生命周期约束 - -日志与清单声明的本插件私有状态属于基础设施,无需单独 permission +清单只声明处理器、配置和资源。宿主回调不需要权限声明,也不按调用阶段授予访问域;类型校验、期限、取消、实例 revision、事务及流资源生命周期仍然生效 ## 插件图标 diff --git a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md index 8beda0a0e..d037dc413 100644 --- a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md +++ b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md @@ -6,8 +6,8 @@ ## 声明与绑定 -清单声明 `upstream_adapter`、版本 `1`、阶段 `upstream`,以及匹配的 `input_formats` / `output_formats`; -需要 `requests` 与 `upstream_connections`。使用 `PluginBuilder::on` 注册 +清单声明 `upstream_adapter`、版本 `1`,以及匹配的 `inputFormats` / `outputFormats`;固定阶段 `upstream` 由作者清单规范化入口生成。 +使用 `PluginBuilder::on` 注册 `methods::UPSTREAM_ADAPTER_REGISTER` 与 `methods::UPSTREAM_ADAPTER_EXECUTE` 注册返回 `UpstreamAdapterRegistration`,包含 1 至 16 个适配器: @@ -25,18 +25,19 @@ 实例绑定沿用 Key、账号组、Provider、模型范围,只允许一次 `upstream` 绑定和 `reject` 故障策略。 同一请求不能同时命中两个适配器;配置准备时拒绝重叠,执行时再次复核。适配器未命中时仍使用原生上游, -已命中但不可用时拒绝请求。安装、授权、配置、启停和升级使用已有插件管理 +已命中但不可用时拒绝请求。安装、配置、启停和升级使用已有插件管理 能力版本、清单版本、进程 RPC 版本和宿主兼容声明格式版本分别判断,见[清单](manifest.md)。 -Provider、业务协议和传输名称不能写入宿主兼容清单的 capability / permission / RPC 版本位置 +Provider、业务协议和传输名称不能写入宿主兼容清单的 capability / RPC 版本位置 ## 执行与受管出站 `TypedCall` 包含只读的 Key、账号、凭据版本、模型、协议、Header 和宿主续接投影; 原始请求正文位于 `call.payload`,原始账号凭据不进入插件输入。宿主首次消费冷流才调用插件 -`disable_fast` 是宿主冻结的请求策略。OpenAI 请求正文在进入适配器前复用原生 Fast 策略处理; -适配器转换为其他上游协议时也须遵守该字段,不能重新启用 Fast / priority 档位 +`disable_fast` 是本次模型执行的有效设置。OpenAI 在 attempt 中间件之前将 Fast 设置应用到正文基线, +适配器收到经过中间件处理的正文;插件显式改写档位后,宿主不会在发送前再次恢复默认值。 +其他请求设置与 Key 作用域的覆盖规则见[模型请求与 attempt](capabilities.md#模型请求与-attempt) HTTP 请求使用 `call.host.upstream_http(request, body).await?`,返回 `HostHttpResponse`: @@ -59,11 +60,20 @@ JSON 可有界收集,SSE 应增量解析,不能把读取块当成完整事 WebSocket 使用 `call.host.upstream_websocket(request).await?`,返回 `UpstreamWebSocketUpgrade::Connected { headers, connection }` 或 `Rejected { status, headers, body }`。 连接提供 `send(kind, body)`、`read()`、`close()`,支持完整文本与二进制消息,Ping/Pong 由宿主处理。 -同一连接一次只执行一个操作,握手拒绝保留 HTTP 状态和正文。底层 `host.upstream.*` 是 SDK 使用的资源协议, -不是额外的业务中间件链 +读写可同时进行,等待上游输出时可以发送控制消息;有先后依赖的消息须顺序等待 `send`。 +同一时刻只允许一个读取者;取消一次 `read()` 等待后,再次读取继续同一次接收,不跳过消息。 +`close` 会唤醒在途操作并释放连接,并发关闭等待同一次完成;读取超时或连接错误也会关闭连接。 +握手拒绝保留 HTTP 状态和正文。底层 `host.upstream.*` 是 SDK 使用的资源协议 -宿主只对声明的基址和精确路径注入已选账号认证,禁止覆盖 Host、认证、Cookie 与握手保护头; -重定向不能携带凭据逃逸。出站复用该账号的代理,代理失败不能回退直连。 +```rust,ignore +let (message, ()) = tokio::try_join!( + connection.read(), + connection.send(WebSocketMessageKind::Text, control_message), +)?; +``` + +宿主先注入已选账号认证,插件显式提供的同名头覆盖默认值。路径支持相对基址或完整 HTTP(S) URL; +声明路径用于标记发送用途,不是访问白名单。出站复用账号代理,代理失败不能回退直连,重定向不自动跟随。 `auxiliary` 路径的出站计入请求副作用水位,防止附件上传等操作在失败后被透明重复执行 ## 事件、结算和续接 diff --git a/backend/crates/gateway-plugin/sdk/src/call/data.rs b/backend/crates/gateway-plugin/sdk/src/call/data.rs index 5ed5c169e..e3b2bea46 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/data.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/data.rs @@ -1,4 +1,4 @@ -//! 基础事实投影;主动刷新由独立的 quota_observations 访问域授权。 +//! 基础事实查询与主动额度刷新。 //! 响应忽略未知字段,以兼容宿主新增事实;查询仍严格校验字段。 use serde::{Deserialize, Serialize}; diff --git a/backend/crates/gateway-plugin/sdk/src/call/host.rs b/backend/crates/gateway-plugin/sdk/src/call/host.rs index 5fd6f066b..34fab2c89 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/host.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/host.rs @@ -174,7 +174,7 @@ pub enum ModelOperation { /// 模型执行回调的请求元数据;正文始终使用 RPC 的独立二进制载荷。 /// -/// Provider/账号是进一步收窄,不是绕过父请求 Key 范围的授权凭证。 +/// Provider/账号用于指定执行目标;显式 Key 决定本次模型执行身份。 #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ModelExecuteRequest { @@ -282,7 +282,7 @@ pub struct AffinityLookupResult { pub account_id: Option, } -/// 模型执行回调的有界事件批次。单个事件使用 GPE1 封套,批次只增加 +/// 模型执行回调的有界事件批次。单个事件使用 GPE2 封套,批次只增加 /// 长度索引,不把原生正文改写成 JSON/base64。 #[derive(Clone, Default)] pub struct ModelEventBatch { diff --git a/backend/crates/gateway-plugin/sdk/src/call/management.rs b/backend/crates/gateway-plugin/sdk/src/call/management.rs index 2b82184bf..1acc84310 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/management.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/management.rs @@ -20,7 +20,7 @@ pub struct ManagementRegistration { pub callbacks: Vec, } -/// 仅接受宿主签发的一次性 state;回调不能访问账号、网络或私有状态宿主端口。 +/// 公开登录回调;宿主签发的一次性 state 用于关联已发起的流程。 #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagementCallback { @@ -28,7 +28,7 @@ pub struct ManagementCallback { pub response_content_types: Vec, } -/// 管理 handler 默认且始终要求管理员身份;浏览器 Cookie、Authorization 不会传入插件。 +/// 管理 handler 默认要求管理员身份;原始请求头完整传入插件。 #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagementRoute { @@ -39,7 +39,7 @@ pub struct ManagementRoute { pub response_content_types: Vec, } -/// 资源必须已在包清单 resources 中声明并校验摘要,公开资源还须单独获得授权。 +/// 资源须在包清单 resources 中声明并校验摘要;`public` 决定是否允许未登录访问。 #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagementResource { @@ -68,14 +68,18 @@ pub struct ManagementRequest { pub path: String, pub query: String, pub content_type: Option, + #[serde(default)] + pub headers: Vec, } -/// 原始响应体独立放在帧 payload;宿主拥有 CSP、安全头及大小限制。 +/// 原始响应体独立放在帧 payload;显式 headers 可覆盖宿主的默认响应头。 #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagementResponse { pub status: u16, pub content_type: String, + #[serde(default)] + pub headers: Vec, } /// `command_line.register` 的只读结果;注册和帮助查询不能执行命令或登录。 diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/http/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/http/mod.rs new file mode 100644 index 000000000..8c66a055a --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/http/mod.rs @@ -0,0 +1,103 @@ +//! 路由前 HTTP 视图;复用 middleware.handle、next 与正文资源调用。 + +use super::MiddlewareHeader; +use serde::{Deserialize, Serialize}; + +/// 正文句柄消费一次;Bytes 使用当前 RPC 的 binary payload,Stream 使用响应流。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Body { + Empty, + Handle { handle: String }, + Bytes, + Stream, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + /// 认证与解压前的运行设置;null 表示主动调用继承宿主或父调用基线。 + pub settings: serde_json::Value, + pub method: String, + pub uri: String, + pub version: Version, + pub headers: Vec, + /// 缺省超时由宿主形成基线;None 显式取消本次 HTTP 处理期限。 + pub timeout_ms: Option, + pub body: Body, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Response { + pub status: u16, + pub version: Version, + pub headers: Vec, + pub body: Body, + /// 返还 next 的原响应时保留该关联,使宿主继续持有传输扩展和资源。 + pub response: Option, + /// 响应流承载会话处理的完成与错误,不承载 HTTP 正文。 + pub session: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum Version { + #[serde(rename = "HTTP/0.9")] + Http09, + #[serde(rename = "HTTP/1.0")] + Http10, + #[serde(rename = "HTTP/1.1")] + Http11, + #[serde(rename = "HTTP/2")] + Http2, + #[serde(rename = "HTTP/3")] + Http3, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Call { + pub settings_sources: serde_json::Value, + pub request_id: String, + pub call_id: String, + pub parent_call_id: Option, + pub request: Request, +} + +/// 数据在 binary payload,trailers 保留多值 header;EOF 不携带正文或 trailers。 +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BodyRead { + pub eof: bool, + pub trailers: Option>, +} + +pub const DISPATCH_METHOD: &str = "host.http.dispatch"; +pub const BODY_READ_METHOD: &str = "host.http.body_read"; +pub const BODY_CLOSE_METHOD: &str = "host.http.body_close"; +pub const BODY_CREATE_METHOD: &str = "host.http.body_create"; +pub const BODY_WRITE_METHOD: &str = "host.http.body_write"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BodyPipe { + pub handle: String, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BodyWrite { + pub handle: String, + pub eof: bool, + pub trailers: Option>, +} + +pub const UPGRADE_METHOD: &str = "host.middleware.upgrade"; +pub const RECEIVE_METHOD: &str = "host.middleware.receive"; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Upgrade { + pub request: Request, + pub protocols: Vec, +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs similarity index 87% rename from backend/crates/gateway-plugin/sdk/src/call/middleware.rs rename to backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs index 0de32e0db..f192cf25b 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/middleware.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs @@ -1,10 +1,14 @@ //! 统一请求/响应中间件的跨进程数据合同。 +pub mod http; +pub mod websocket; + use serde::{Deserialize, Serialize}; pub const HANDLE_METHOD: &str = "middleware.handle"; pub const NEXT_METHOD: &str = "host.middleware.next"; pub const BODY_READ_METHOD: &str = "host.middleware.body_read"; +pub const BODY_FACTS_METHOD: &str = "host.middleware.body_facts"; pub const BODY_CLOSE_METHOD: &str = "host.middleware.body_close"; const BODY_FRAME_PREFIX: [u8; 4] = *b"GMB1"; @@ -56,11 +60,17 @@ pub enum MiddlewareHeaderMutation { Append { name: String, value: Vec }, } -/// `middleware.handle` 的请求头。正文位于 RPC binary payload;未授权读取时为空。 +/// `middleware.handle` 的请求头。完整正文位于 RPC binary payload。 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MiddlewareRequestHead { + pub settings_sources: serde_json::Value, + /// 当前请求的完整有效执行设置;尚未建立可改写设置的边界使用 null。 + #[serde(default)] + pub settings: serde_json::Value, pub request_id: String, + pub client_key_id: String, + pub account_group_ids: Vec, pub mount: MiddlewareMount, #[serde(default, skip_serializing_if = "Option::is_none")] pub attempt_index: Option, @@ -76,8 +86,6 @@ pub struct MiddlewareRequestHead { pub account_id: Option, #[serde(default)] pub headers: Vec, - /// `true` 表示 binary payload 是完整请求正文;`false` 表示正文未向插件投影。 - pub body_visible: bool, } /// 调用 next 时如何处理宿主保存的原始请求正文。 @@ -98,7 +106,7 @@ pub enum RequestFeature { JsonSchema, } -/// middleware v2:插件承担的功能转换与额外上游需求,均不覆盖正文推导的事实。 +/// 插件承担的功能转换与额外上游需求,均不覆盖正文推导的事实。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CapabilityDeclaration { @@ -110,6 +118,9 @@ pub struct CapabilityDeclaration { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MiddlewareNextRequest { + /// None 保留原设置;Some 是完整替换,字段删除必须符合对应设置类型。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub protocol: Option, #[serde(default)] @@ -133,6 +144,8 @@ pub struct MiddlewareBodyHandle { pub struct MiddlewareNextResponse { /// 绑定本次 next 结果的宿主响应身份;只能在当前父调用内返还。 pub response: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, pub protocol: String, pub status: u16, #[serde(default)] @@ -141,7 +154,7 @@ pub struct MiddlewareNextResponse { pub body: Option, } -/// 最终正文来源。`PassThrough` 不读取正文,也不要求响应正文读写授权。 +/// 最终正文来源;`PassThrough` 直接转交下游正文,不触发读取。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum MiddlewareResponseBody { @@ -175,6 +188,20 @@ pub struct MiddlewareBodyRead { pub maximum_bytes: u32, } +/// 读取当前源 frame 的宿主事实,不消费或复制原结算所有权。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MiddlewareBodyFacts { + pub handle: String, + pub source_id: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MiddlewareBodyFactsResult { + pub present: bool, +} + /// `eof` 只在空 payload 时成立;结构化 frame 不拆成任意网络分块。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -209,8 +236,10 @@ pub enum MiddlewareBodyDisposition { } /// 插件替换正文流中的一个完整 frame;正文不进入 JSON metadata。 -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq)] pub struct MiddlewareBodyFrame { + /// 按需读取的来源快照,不进入插件返回的正文编码。 + pub facts: Option>, pub payload: Vec, pub terminal: bool, source_id: u64, @@ -221,6 +250,7 @@ impl MiddlewareBodyFrame { #[must_use] pub fn new(payload: Vec, terminal: bool) -> Self { Self { + facts: None, payload, terminal, source_id: 0, @@ -238,6 +268,7 @@ impl MiddlewareBodyFrame { return Err(MiddlewareBodyFrameError); } Ok(Self { + facts: None, payload, terminal, source_id, @@ -317,6 +348,7 @@ impl MiddlewareBodyFrame { return Err(MiddlewareBodyFrameError); } Ok(Self { + facts: None, payload: bytes[14..].to_vec(), terminal, source_id, diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket/mod.rs new file mode 100644 index 000000000..6ae90b2c7 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket/mod.rs @@ -0,0 +1,44 @@ +//! 双工消息使用二进制 payload 或惰性句柄,避免强制 JSON/UTF-8 解码。 +use super::MiddlewareHeader; +use serde::{Deserialize, Serialize}; +pub const SEND_METHOD: &str = "host.middleware.send"; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Incoming, + Outgoing, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Kind { + Text, + Binary, + Ping, + Pong, + Close { code: Option }, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +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, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Call { + pub connection_id: String, + pub direction: Direction, + pub headers: Vec, + pub message: Message, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Read { + pub eof: bool, +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/mod.rs index 9322d483f..2177bebd0 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/mod.rs @@ -13,3 +13,5 @@ pub mod policy; pub mod registration; pub mod resources; pub mod upstream_adapter; + +pub mod services; diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs b/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs index e47d9b694..73a023e5b 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs @@ -10,13 +10,32 @@ pub const MAX_EXECUTION_PAYLOAD_BYTES: usize = 8 * 1024 * 1024; pub struct ExecutionEncodingError; pub(super) fn encode_event(mut event: ExecutionEvent) -> Result, ExecutionEncodingError> { - let wire = raw_wire(&mut event).map(std::mem::take).unwrap_or_default(); - pack(*b"GPE1", &event, [wire]) + let wire = raw_wire(event.wire.as_mut()) + .map(std::mem::take) + .unwrap_or_default(); + let origin = raw_wire( + event + .host + .as_mut() + .and_then(|host| host.middleware_origin_wire.as_mut()), + ) + .map(std::mem::take) + .unwrap_or_default(); + pack(*b"GPE2", &event, [wire, origin]) } pub(super) fn decode_event(bytes: &[u8]) -> Result { - let (mut event, [wire]) = unpack(*b"GPE1", bytes)?; - attach(raw_wire(&mut event), wire)?; + let (mut event, [wire, origin]): (ExecutionEvent, _) = unpack(*b"GPE2", bytes)?; + attach(raw_wire(event.wire.as_mut()), wire)?; + attach( + raw_wire( + event + .host + .as_mut() + .and_then(|host| host.middleware_origin_wire.as_mut()), + ), + origin, + )?; Ok(event) } @@ -98,8 +117,8 @@ fn attach(target: Option<&mut Vec>, bytes: &[u8]) -> Result<(), ExecutionEnc Ok(()) } -fn raw_wire(event: &mut ExecutionEvent) -> Option<&mut Vec> { - match &mut event.wire.as_mut()?.payload { +fn raw_wire(wire: Option<&mut super::WireEvent>) -> Option<&mut Vec> { + match &mut wire?.payload { WirePayload::Json { raw_sse, .. } => raw_sse.as_mut(), WirePayload::RawSse { frame } => Some(frame), WirePayload::RawJson { body } | WirePayload::RawBody { body } => Some(body), diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/facts/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/model/facts/mod.rs new file mode 100644 index 000000000..dbe50e24b --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/model/facts/mod.rs @@ -0,0 +1,126 @@ +//! 宿主确认的执行事实快照。读取快照不会取得原执行的计费或资源所有权。 + +use serde::{Deserialize, Serialize}; + +use super::WireEvent; +use crate::call::{ + middleware::MiddlewareHeader, + observation::{RequestMoney, RequestTimings}, +}; + +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionFacts { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub costs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_update: Option, + pub middleware_transformed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub middleware_origin_wire: Option, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "source", rename_all = "snake_case", deny_unknown_fields)] +pub enum ExecutionCost { + Calculated { + total: RequestMoney, + breakdown: Option>, + }, + ProviderReported { + total: RequestMoney, + }, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CostBreakdown { + pub long_context_billing_applied: bool, + pub image: Option, + pub input_amount: RequestMoney, + pub output_amount: RequestMoney, + pub cache_read_amount: RequestMoney, + pub cache_write_amount: RequestMoney, + pub standard_amount: RequestMoney, + pub total_amount: RequestMoney, + pub input_price_per_million: RequestMoney, + pub output_price_per_million: RequestMoney, + pub cache_read_price_per_million: RequestMoney, + pub cache_write_price_per_million: RequestMoney, + pub service_tier: Option, + pub multiplier_percent: u32, + pub custom_multiplier_bps: u32, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImageCostBreakdown { + pub input_tokens: u64, + pub cached_tokens: u64, + pub input_amount: RequestMoney, + pub cache_read_amount: RequestMoney, + pub input_price_per_million: RequestMoney, + pub cache_read_price_per_million: RequestMoney, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResponseObservation { + pub transport: String, + pub http_version: Option, + pub websocket_pool: Option, + pub status_code: Option, + pub request_id: Option, + pub service_tier: Option, + pub upstream_response_model: Option, + pub timings: RequestTimings, + pub client_headers: Vec, + /// Provider 已产生的完整观测 JSON 文本,保留原始表示。 + pub provider_metadata: Option, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionState { + pub provider: String, + pub payload: serde_json::Map, + pub extension_owner: Option, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionOwner { + pub instance_id: String, + pub contribution_id: String, + pub adapter_id: String, + pub generation: u64, + pub incarnation: String, + pub connection_local: bool, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderCallMetadata { + pub provider: String, + pub upstream_model: Option, + pub provider_account_id: String, + pub upstream_request_id: Option, + pub transport: String, + pub selection_observation: Option, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionObservation { + pub account_selection_wait_ms: u64, + pub capacity: Option, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccountCapacity { + pub used_slots: u64, + pub total_slots: u64, +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs index 90461f659..74b23c593 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs @@ -3,9 +3,10 @@ use serde::{Deserialize, Serialize}; pub(in crate::call) mod codec; +pub mod facts; pub use codec::{ExecutionEncodingError, MAX_EXECUTION_PAYLOAD_BYTES}; -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ContentKind { Text, @@ -15,7 +16,7 @@ pub enum ContentKind { Audio, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FinishReason { Stop, @@ -25,7 +26,7 @@ pub enum FinishReason { Other, } -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Usage { pub input_tokens: Option, @@ -40,13 +41,16 @@ pub struct Usage { /// 每个流分块是一个完整封套;同一 wire 的事实必须合并,避免重复交付客户端。 /// 载荷及私有状态不实现 Debug,且不能用拆分封套绕过宿主大小和序列检查。 -#[derive(Clone, Default, Serialize, Deserialize)] +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ExecutionEvent { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub facts: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub wire: Option, + /// 宿主确认的来源事实;回传快照不会触发计量、续接或发送状态更新。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option>, } impl ExecutionEvent { @@ -89,7 +93,7 @@ impl ExecutionEvent { } } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum CanonicalEvent { Started { @@ -124,14 +128,14 @@ pub enum CanonicalEvent { }, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct WireEvent { pub protocol: String, pub payload: WirePayload, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum WirePayload { Json { diff --git a/backend/crates/gateway-plugin/sdk/src/call/observation.rs b/backend/crates/gateway-plugin/sdk/src/call/observation.rs index 87945cecf..fc0e7cef8 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/observation.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/observation.rs @@ -1,14 +1,207 @@ -//! 只读响应观察调用的数据合同。 +//! 请求完成与实际上游 WebSocket 事件的统一观察合同。 use serde::{Deserialize, Serialize}; -/// 实际上游 WebSocket 响应事件。 -/// -/// 原始 JSON 在 RPC 二进制载荷中独立传递。载荷未纳入本次有界观察时 -/// `payload_included=false`、载荷为空,且不会通过事件名或头字段旁路泄漏正文。 +use crate::SendState; + +/// 订阅决定投递哪些事件,不裁剪事件中的字段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + RequestCompleted, + #[serde(rename = "websocket_response")] + WebSocketResponse, +} + +/// 元数据在 RPC 参数中传递;WebSocket 原始帧独立放在二进制载荷中。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "event", + content = "data", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum Event { + RequestCompleted(Box), + #[serde(rename = "websocket_response")] + WebSocketResponse(Box), +} + +impl Event { + #[must_use] + pub fn request_id(&self) -> &str { + match self { + Self::RequestCompleted(event) => &event.request_id, + Self::WebSocketResponse(event) => &event.request_id, + } + } +} + +/// 请求终态分类;`rejected` 表示请求在进入 Provider 执行前被宿主拒绝。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestOutcome { + Succeeded, + Failed, + Rejected, + Cancelled, + Incomplete, +} + +/// Core 对单次请求最终费用的可信程度。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestCostStatus { + Known, + Unknown, +} + +/// 最终费用事实的来源;观察插件只消费结果,不重新计价。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestCostSource { + ProviderReported, + Calculated, + Unavailable, +} + +/// 精确十进制金额;字符串保留宿主的定点精度。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ObserveWebSocketResponse { +pub struct RequestMoney { + pub amount: String, + pub currency: String, +} + +/// 用量观察取得的最终费用;未知费用没有 `total`,不得解释为零。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestCost { + pub status: RequestCostStatus, + pub source: RequestCostSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, +} + +/// 用量观察取得的毫秒级阶段耗时;缺失字段表示宿主没有该项事实。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestTimings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport_decision_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_event_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_reasoning_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_text_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_token_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_processing_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, +} + +/// 非成功请求的最终分类、发送状态与重试事实。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestFailure { + pub outcome: RequestOutcome, + pub send_state: SendState, + pub attempt_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_status_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_status_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, +} + +/// 请求生命周期观察取得的最终宿主事实。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestTerminal { + pub outcome: RequestOutcome, + pub send_state: SendState, + pub attempt_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_status_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, +} + +/// 用量观察取得的最终标准化用量;缺失字段表示宿主没有该项事实。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, +} + +/// 一次完成事件包含宿主已确认的全部终态、用量、费用与耗时。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestCompleted { + /// 不重投时仍保持稳定,供插件自行去重和关联日志。 + pub event_id: String, + pub request_id: String, + pub config_revision: u64, + pub operation: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_key_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + /// 本次实际选中账号使用的上游模型,未进入上游时可能不存在。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_model: Option, + /// 上游响应报告的模型,与请求中的模型名称分开保存。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + pub completed_at_ms: u64, + pub terminal: RequestTerminal, + pub usage: RequestUsage, +} + +/// 实际上游 WebSocket 响应事件。 +/// +/// 原始 JSON 在 RPC 二进制载荷中独立传递;`payload_included` 表示本次 +/// 事件是否包含原始字节,元数据不能替代原始载荷。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WebSocketResponse { pub event_id: String, pub request_id: String, pub config_revision: u64, @@ -21,7 +214,7 @@ pub struct ObserveWebSocketResponse { pub payload_included: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_model: Option, - /// 实际 attempt 选择的账号;属于 requests 域的请求处理事实,不包含账号资料或凭据。 + /// 实际 attempt 选择的账号。 #[serde(default, skip_serializing_if = "Option::is_none")] pub account_id: Option, /// 事件名属于原始响应内容,只在 `payload_included=true` 时提供。 diff --git a/backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs index 4e5b5333b..f9ada5b3c 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs @@ -1,4 +1,4 @@ -//! 路由、调度与最终观测调用的跨进程数据合同。 +//! 路由、调度与重试调用的跨进程数据合同。 use serde::{Deserialize, Serialize}; @@ -36,7 +36,7 @@ pub enum RetryDecision { Retry, } -/// 策略插件可见的安全 HTTP 头;值使用 base64 保留非 UTF-8 字节。 +/// 策略调用的完整 HTTP 头;值使用 base64 保留非 UTF-8 字节。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PolicyHeader { @@ -44,7 +44,7 @@ pub struct PolicyHeader { pub value_base64: String, } -/// 模型路由调用。原始正文在 RPC 二进制载荷中独立传递;无正文读取授权时载荷为空。 +/// 模型路由调用;原始正文在 RPC 二进制载荷中独立传递。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ModelRouteRequest { @@ -72,7 +72,7 @@ pub enum ModelRouteDecision { Reject, } -/// 账号调度器可见的安全候选事实,不包含 credential 或账号资料。 +/// 候选账号的即时调度指标;凭据和账号资料通过宿主账号接口读取。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AccountScheduleCandidate { @@ -92,7 +92,7 @@ pub struct AccountScheduleCandidate { pub first_output_latency_ms: Option, } -/// 账号调度调用。Key 与账号组仅供宿主匹配绑定,不发送给插件。 +/// 账号调度输入;候选账号及即时调度指标由宿主冻结。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AccountScheduleRequest { @@ -112,163 +112,3 @@ pub enum AccountScheduleDecision { Delegate, Reject, } - -/// 请求终态分类;`rejected` 表示请求在进入 Provider 执行前被宿主拒绝。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RequestOutcome { - Succeeded, - Failed, - Rejected, - Cancelled, - Incomplete, -} - -/// Core 对单次请求最终费用的可信程度。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RequestCostStatus { - Known, - Unknown, -} - -/// 最终费用事实的来源;Usage 插件只消费结果,不重新计价。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RequestCostSource { - ProviderReported, - Calculated, - Unavailable, -} - -/// 精确十进制金额;字符串保留宿主的定点精度。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RequestMoney { - pub amount: String, - pub currency: String, -} - -/// 用量观察取得的最终费用;未知费用没有 `total`,不得解释为零。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RequestCost { - pub status: RequestCostStatus, - pub source: RequestCostSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total: Option, -} - -/// 用量观察取得的毫秒级阶段耗时;缺失字段表示宿主没有该项事实。 -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RequestTimings { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub transport_decision_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub connect_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub headers_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub first_event_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub first_reasoning_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub first_text_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub first_token_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_processing_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub latency_ms: Option, -} - -/// 非成功请求的安全摘要,不包含错误消息、原始正文或诊断载荷。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RequestFailure { - pub outcome: RequestOutcome, - pub send_state: SendState, - pub attempt_count: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_status_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upstream_status_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retry_after_ms: Option, -} - -/// 请求生命周期观察取得的最终宿主事实。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RequestTerminal { - pub outcome: RequestOutcome, - pub send_state: SendState, - pub attempt_count: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_status_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, -} - -/// 用量观察取得的最终标准化用量;缺失字段表示宿主没有该项事实。 -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RequestUsage { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cached_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_write_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image_input_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image_output_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_tokens: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cost: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timings: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure: Option, -} - -/// 同一实例的请求生命周期与用量订阅合并为一个最终观测调用。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ObserveRequest { - /// 不重投时仍保持稳定,供插件自行去重和关联日志。 - pub event_id: String, - pub request_id: String, - pub config_revision: u64, - pub operation: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_key_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub account_id: Option, - /// 本次实际选中账号使用的上游模型,未进入上游时可能不存在。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub upstream_model: Option, - /// 上游响应报告的模型,与请求中的模型名称分开保存。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub response_model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub service_tier: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requested_model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - pub completed_at_ms: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub terminal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage: Option, -} diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs new file mode 100644 index 000000000..d531eb744 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs @@ -0,0 +1,49 @@ +//! 公开服务的类型化操作合同;名称用于分派,不承担授权。 + +pub mod settings; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +pub const CALL_METHOD: &str = "host.services.call"; + +pub trait Operation { + const NAME: &'static str; + type Input: Serialize + DeserializeOwned + Send + 'static; + type Output: Serialize + DeserializeOwned + Send + 'static; +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + pub operation: String, + pub input: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServiceError { + pub kind: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} +impl std::error::Error for ServiceError {} + +pub type Response = Result; + +/// 洋葱层保留父子调用标识;input 由 Operation 对应的类型解释。 +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Call { + pub operation: String, + pub call_id: String, + pub parent_call_id: Option, + pub request_id: String, + pub input: serde_json::Value, +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs new file mode 100644 index 000000000..6a5fecfb2 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs @@ -0,0 +1,282 @@ +// 从宿主设置类型与 service/settings.rs 生成;更新命令见 SDK 维护说明。 +use super::Operation; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + num::NonZeroU64, +}; +pub type ProviderRequestProfiles = BTreeMap>; +pub type ProviderRequestProfileUpdates = + BTreeMap>>; +pub type ModelMappings = BTreeMap; +pub type Revision = NonZeroU64; +pub type RotationStrategy = String; +pub type PricingOverrides = BTreeMap>; +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeSettings { + pub request_profiles: ProviderRequestProfiles, + pub config_revision: Revision, + pub request_location_enabled: bool, + pub request_location: RequestLocation, + pub model_mappings: ModelMappings, + pub refresh_margin_seconds: u64, + pub refresh_concurrency: u32, + pub max_concurrent_per_account: u32, + pub request_interval_ms: u64, + pub max_waiting_per_key: u32, + pub max_waiting_per_account: u32, + pub concurrency_wait_timeout_seconds: u32, + pub responses_max_decompressed_body_bytes: u64, + pub smart_scheduling: SmartSchedulingConfig, + pub rotation_strategy: RotationStrategy, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, + pub usage_retention_days: u32, + pub ops_event_retention_days: u32, + pub audit_retention_days: u32, + pub account_auto_freeze_enabled: bool, + pub account_auto_freeze_threshold: u32, + pub account_auto_freeze_window_seconds: u64, + pub account_auto_freeze_duration_seconds: u64, + pub account_auto_freeze_probe_enabled: bool, + pub account_auto_freeze_probe_model: Option, + pub account_auto_freeze_adaptive_concurrency: bool, + pub account_warmup_enabled: bool, + pub account_warmup_schedule_time: String, + pub account_warmup_model: Option, + pub updated_at: String, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReplaceRuntimeSettings { + /// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。 + pub expected_revision: Revision, + /// 只覆盖提交的 Provider;未提交项保留当前持久值。 + pub request_profile_updates: ProviderRequestProfileUpdates, + pub request_location_enabled: bool, + pub request_location: RequestLocation, + pub model_mappings: ModelMappings, + pub refresh_margin_seconds: u64, + pub refresh_concurrency: u32, + pub max_concurrent_per_account: u32, + pub request_interval_ms: u64, + pub max_waiting_per_key: u32, + pub max_waiting_per_account: u32, + pub concurrency_wait_timeout_seconds: u32, + pub responses_max_decompressed_body_bytes: u64, + pub smart_scheduling: SmartSchedulingConfig, + pub rotation_strategy: RotationStrategy, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, + pub usage_retention_days: u32, + pub ops_event_retention_days: u32, + pub audit_retention_days: u32, + pub account_auto_freeze_enabled: bool, + pub account_auto_freeze_threshold: u32, + pub account_auto_freeze_window_seconds: u64, + pub account_auto_freeze_duration_seconds: u64, + pub account_auto_freeze_probe_enabled: bool, + pub account_auto_freeze_probe_model: Option, + pub account_auto_freeze_adaptive_concurrency: bool, + pub account_warmup_enabled: bool, + pub account_warmup_schedule_time: String, + pub account_warmup_model: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdminApiKeyMutation { + pub config_revision: Revision, + pub exists: bool, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegeneratedAdminApiKey { + pub mutation: AdminApiKeyMutation, + pub key: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum MutationActor { + AdminSession { admin_user_id: String }, + AdminApiKey, + System, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct MutationContext { + pub actor: MutationActor, + pub request_id: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestLocation { + pub country: String, + pub region: String, + pub city: String, + pub timezone: String, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SmartSchedulingConfig { + pub load_weight: f64, + pub quota_weight: f64, + pub health_weight: f64, + pub latency_weight: f64, + pub reset_weight: f64, + pub queue_weight: f64, + pub prefer_higher_weight: bool, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TokenPriceOverride { + pub input: String, + pub output: String, + pub cache_read: String, + pub cache_write: String, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ModelPriceOverride { + pub multiplier_bps: u32, + pub bands: BTreeMap, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PricingCatalog { + pub defaults: PricingOverrides, + pub overrides: PricingOverrides, + pub synced: PricingOverrides, + pub synced_at: Option, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PricingSyncPreview { + pub prices: PricingOverrides, + pub skipped: Vec, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncPricing { + pub preview: PricingSyncPreview, + pub models: BTreeMap>, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PricingChange { + Replace(ModelPriceOverride), + Multiplier(u32), + Reset, + Delete, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdatePricing { + pub provider: String, + pub models: Vec, + pub change: PricingChange, +} +pub struct Load; +impl Operation for Load { + const NAME: &'static str = "settings.load"; + type Input = (); + type Output = RuntimeSettings; +} +pub struct Replace; +impl Operation for Replace { + const NAME: &'static str = "settings.replace"; + type Input = (MutationContext, ReplaceRuntimeSettings); + type Output = RuntimeSettings; +} +pub struct ApiKeyExists; +impl Operation for ApiKeyExists { + const NAME: &'static str = "settings.admin_api_key_exists"; + type Input = (); + type Output = bool; +} +pub struct RegenerateApiKey; +impl Operation for RegenerateApiKey { + const NAME: &'static str = "settings.regenerate_admin_api_key"; + type Input = MutationContext; + type Output = RegeneratedAdminApiKey; +} +pub struct DeleteApiKey; +impl Operation for DeleteApiKey { + const NAME: &'static str = "settings.delete_admin_api_key"; + type Input = MutationContext; + type Output = AdminApiKeyMutation; +} +pub struct Pricing; +impl Operation for Pricing { + const NAME: &'static str = "settings.pricing"; + type Input = (); + type Output = PricingCatalog; +} +pub struct PreviewPricingSync; +impl Operation for PreviewPricingSync { + const NAME: &'static str = "settings.preview_pricing_sync"; + type Input = (); + type Output = PricingSyncPreview; +} +pub struct Sync; +impl Operation for Sync { + const NAME: &'static str = "settings.sync_pricing"; + type Input = (MutationContext, SyncPricing); + type Output = (); +} +pub struct Update; +impl Operation for Update { + const NAME: &'static str = "settings.update_pricing"; + type Input = (MutationContext, UpdatePricing); + type Output = (); +} +pub struct ClientProfileOptions; +impl Operation for ClientProfileOptions { + const NAME: &'static str = "settings.client_profile_options"; + type Input = String; + type Output = serde_json::Map; +} +pub struct PreviewClientProfile; +impl Operation for PreviewClientProfile { + const NAME: &'static str = "settings.preview_client_profile"; + type Input = (String, Option>); + type Output = serde_json::Map; +} +impl From for ReplaceRuntimeSettings { + fn from(settings: RuntimeSettings) -> Self { + Self { + expected_revision: settings.config_revision, + request_profile_updates: settings + .request_profiles + .into_iter() + .map(|(provider, value)| (provider, Some(value))) + .collect(), + request_location_enabled: settings.request_location_enabled, + request_location: settings.request_location, + model_mappings: settings.model_mappings, + refresh_margin_seconds: settings.refresh_margin_seconds, + refresh_concurrency: settings.refresh_concurrency, + max_concurrent_per_account: settings.max_concurrent_per_account, + request_interval_ms: settings.request_interval_ms, + max_waiting_per_key: settings.max_waiting_per_key, + max_waiting_per_account: settings.max_waiting_per_account, + concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds, + responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes, + smart_scheduling: settings.smart_scheduling, + rotation_strategy: settings.rotation_strategy, + min_codex_desktop_version: settings.min_codex_desktop_version, + min_codex_cli_version: settings.min_codex_cli_version, + usage_retention_days: settings.usage_retention_days, + ops_event_retention_days: settings.ops_event_retention_days, + audit_retention_days: settings.audit_retention_days, + account_auto_freeze_enabled: settings.account_auto_freeze_enabled, + account_auto_freeze_threshold: settings.account_auto_freeze_threshold, + account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds, + account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds, + account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled, + account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model, + account_auto_freeze_adaptive_concurrency: settings + .account_auto_freeze_adaptive_concurrency, + account_warmup_enabled: settings.account_warmup_enabled, + account_warmup_schedule_time: settings.account_warmup_schedule_time, + account_warmup_model: settings.account_warmup_model, + } + } +} 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 f0cf687d3..796f64f91 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 @@ -130,7 +130,7 @@ pub struct UpstreamContinuation { pub state: serde_json::Map, } -/// 一个完整 GPE1 事件与可选的结算/续接事实;见 [`Self::encode`]。 +/// 一个完整 GPE2 事件与可选的结算/续接事实;见 [`Self::encode`]。 pub struct UpstreamAdapterEvent { pub event: ExecutionEvent, /// 上游回显的最新服务档位,可在终态确定;是否参与计价由内置 Provider 决定。 @@ -150,7 +150,7 @@ impl UpstreamAdapterEvent { } } - /// 编码有界事件,wire 原字节仍使用 GPE1 二进制段。 + /// 编码有界事件,wire 原字节仍使用 GPE2 二进制段。 pub fn encode(self) -> Result, super::model::ExecutionEncodingError> { codec::encode(self) } diff --git a/backend/crates/gateway-plugin/sdk/src/capability.rs b/backend/crates/gateway-plugin/sdk/src/capability.rs index 8550751d1..980eb0ad1 100644 --- a/backend/crates/gateway-plugin/sdk/src/capability.rs +++ b/backend/crates/gateway-plugin/sdk/src/capability.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, fmt}; use serde::{Deserialize, Deserializer, Serialize, de}; -/// 能力只声明可提供的行为,不等于管理员授予的回调权限。 +/// 能力描述插件提供的处理器,不承担宿主资源授权。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Capability { @@ -13,9 +13,7 @@ pub enum Capability { RetryPolicy, Middleware, UpstreamAdapter, - RequestLifecycle, - WebSocketObserver, - Usage, + Observer, CommandLine, Management, Maintenance, @@ -26,15 +24,13 @@ impl Capability { #[must_use] pub const fn contract_versions(self) -> &'static [u32] { match self { - Self::Middleware => &[1, 2], + Self::Middleware => &[3], Self::FrontendAuthentication | Self::Scheduler | Self::ModelRouter | Self::ModelCatalog | Self::RetryPolicy - | Self::RequestLifecycle - | Self::WebSocketObserver - | Self::Usage + | Self::Observer | Self::CommandLine | Self::Management | Self::Maintenance => &[1], @@ -53,16 +49,14 @@ impl Capability { Self::RetryPolicy => "retry_policy", Self::Middleware => "middleware", Self::UpstreamAdapter => "upstream_adapter", - Self::RequestLifecycle => "request_lifecycle", - Self::WebSocketObserver => "web_socket_observer", - Self::Usage => "usage", + Self::Observer => "observer", Self::CommandLine => "command_line", Self::Management => "management", Self::Maintenance => "maintenance", } } - /// 固定调用阶段;只有中间件需要作者显式选择 request/attempt。 + /// 固定调用阶段;只有中间件需要作者显式选择挂载边界。 #[must_use] pub const fn fixed_stages(self) -> &'static [Stage] { match self { @@ -73,7 +67,7 @@ impl Capability { Self::RetryPolicy => &[Stage::Retry], Self::Middleware => &[], Self::UpstreamAdapter => &[Stage::Upstream], - Self::RequestLifecycle | Self::WebSocketObserver | Self::Usage => &[Stage::Observation], + Self::Observer => &[Stage::Observation], Self::CommandLine => &[Stage::CommandLine], Self::Management => &[Stage::Management], Self::Maintenance => &[Stage::Maintenance], @@ -81,7 +75,7 @@ impl Capability { } } -/// 调用阶段由宿主签发,插件不能通过方法参数提升阶段。 +/// 调用阶段描述当前处理器位置,不限定插件访问宿主资源。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Stage { @@ -91,6 +85,10 @@ pub enum Stage { Routing, Scheduling, Retry, + Http, + Service, + #[serde(rename = "websocket")] + WebSocket, Request, Attempt, /// Core 登记已选账号的 attempt 后才启动的受管上游执行。 @@ -98,7 +96,7 @@ pub enum Stage { Observation, Management, CommandLine, - /// 公共登录回调不继承插件实例的任何宿主回调权限。 + /// 未登录客户端触发的插件管理调用。 PublicManagement, /// 宿主针对已发布实例签发的幂等维护调用。 Maintenance, @@ -163,100 +161,3 @@ where deserializer.deserialize_map(ContributionsVisitor) } - -/// 插件可访问的稳定资源域;动作与后续资源限制由接口合同表达,不编码在域名中。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Permission { - Network, - Models, - Accounts, - Data, - Requests, - PublicEndpoints, - Groups, - Keys, - KeyBudgets, - QuotaObservations, - UpstreamConnections, -} - -impl Permission { - /// 当前公开访问域,安装摘要与授权校验复用同一集合。 - pub const ALL: [Self; 11] = [ - Self::Network, - Self::Models, - Self::Accounts, - Self::Data, - Self::Requests, - Self::PublicEndpoints, - Self::Groups, - Self::Keys, - Self::KeyBudgets, - Self::QuotaObservations, - Self::UpstreamConnections, - ]; - - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Network => "network", - Self::Models => "models", - Self::Accounts => "accounts", - Self::Data => "data", - Self::Requests => "requests", - Self::PublicEndpoints => "public_endpoints", - Self::Groups => "groups", - Self::Keys => "keys", - Self::KeyBudgets => "key_budgets", - Self::QuotaObservations => "quota_observations", - Self::UpstreamConnections => "upstream_connections", - } - } - - /// 安装摘要显示名。 - #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::Network => "联网", - Self::Models => "模型调用", - Self::Accounts => "账号与凭据", - Self::Data => "基础数据", - Self::Requests => "请求处理", - Self::PublicEndpoints => "公开入口", - Self::Groups => "专用账号分组", - Self::Keys => "专用 API Key", - Self::KeyBudgets => "API Key 预算", - Self::QuotaObservations => "账号额度观测", - Self::UpstreamConnections => "账号上游连接", - } - } - - /// 面向安装确认的访问含义;账号域明确包含原始凭据和修改权限。 - #[must_use] - pub const fn description(self) -> &'static str { - match self { - Self::Network => "访问网络", - Self::UpstreamConnections => { - "使用当前请求所选账号的身份与代理连接插件声明的上游,不提供原始凭据读取或账号修改" - } - Self::Models => "查询模型与 API Key 信息并调用模型,可能产生消耗", - Self::Accounts => "读取和修改账号,包括访问原始凭据", - Self::Data => { - "在管理、命令或维护入口只读查询账号、Key 的基础信息与已有额度观测,不包含凭据或修改权限" - } - Self::Requests => "查看和处理请求、响应、路由及账号选择", - Self::PublicEndpoints => "提供无需登录即可访问的资源与回调入口", - Self::Groups => { - "创建本插件的分组,可将所有现有及未来新增账号加入或移出这些分组,不修改其他分组" - } - Self::Keys => "创建绑定本插件分组的 API Key,不读取密钥明文或修改管理员创建的 Key", - Self::QuotaObservations => { - "查询所有现有及未来账号的额度观测,并通过宿主刷新上游观测;不读取凭据、不执行上游额度重置" - } - Self::KeyBudgets => { - "查询所有现有及未来新增 Key 的预算、修改日/周金额上限和重置用量,包括管理员和其他插件创建的 Key;不读取密钥或修改其他配置" - } - } - } -} diff --git a/backend/crates/gateway-plugin/sdk/src/client/data.rs b/backend/crates/gateway-plugin/sdk/src/client/data.rs index 7f59839b1..2a9a4c11b 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/data.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/data.rs @@ -7,7 +7,7 @@ impl HostClient { /// /// # Errors /// - /// 未获得 data 权限、调用阶段不符、Key 不存在或宿主读取失败时返回错误。 + /// Key 不存在或宿主读取失败时返回错误。 pub async fn key_facts( &self, query: data::ClientKeyFactsQuery, @@ -16,10 +16,10 @@ impl HostClient { } /// 通过宿主刷新账号额度观测,返回与 quota_facts 相同的非秘密投影。 - /// 需要 quota_observations 权限;不修改上游额度,也不自动重置任何 Key。 + /// 不修改上游额度,也不自动重置任何 Key。 /// /// # Errors - /// 未授权、阶段不符、账号不支持刷新或 Provider 查询失败时返回错误。 + /// 账号不支持刷新或 Provider 查询失败时返回错误。 pub async fn refresh_account_quota( &self, query: data::QuotaFactsQuery, @@ -27,11 +27,11 @@ impl HostClient { payload_call(self, data::QUOTA_REFRESH, query).await } - /// 在已授权的 management/command_line/maintenance 调用中分页读取账号基础事实。 + /// 分页读取账号基础事实。 /// /// # Errors /// - /// 未获得 data 权限、调用阶段不符、参数无效或宿主读取失败时返回错误。 + /// 参数无效或宿主读取失败时返回错误。 pub async fn account_facts( &self, query: data::AccountFactsQuery, @@ -43,7 +43,7 @@ impl HostClient { /// /// # Errors /// - /// 未获得 data 或 quota_observations 权限、调用阶段不符、账号不存在或宿主读取失败时返回错误。 + /// 账号不存在或宿主读取失败时返回错误。 pub async fn quota_facts( &self, query: data::QuotaFactsQuery, diff --git a/backend/crates/gateway-plugin/sdk/src/client/http.rs b/backend/crates/gateway-plugin/sdk/src/client/http.rs index 6f8b95662..fde747614 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/http.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/http.rs @@ -2,7 +2,7 @@ use serde::{Serialize, de::DeserializeOwned}; -use super::{HostClient, SessionError}; +use super::{HostClient, HostReply, SessionError, read::PendingRead}; use crate::{ ErrorCode, PluginFault, call::{host as wire, upstream_adapter::UpstreamHttpRequest}, @@ -20,13 +20,14 @@ pub struct HostHttpBody { host: HostClient, stream: Option, upstream: bool, + read: PendingRead>, } impl HostClient { - /// 使用普通网络授权发送请求;账号上游使用 [`Self::upstream_http`]。 + /// 使用受管 HTTP 发送请求;账号上游使用 [`Self::upstream_http`]。 /// /// # Errors - /// 权限、期限、请求或网络无效时失败。 + /// 期限、请求或网络无效时失败。 pub async fn http( &self, request: wire::HttpRequest, @@ -39,7 +40,7 @@ impl HostClient { /// 由宿主附加已选账号的认证与代理,返回统一的惰性正文。 /// /// # Errors - /// 路径、权限、期限、网络或宿主响应无效时失败。 + /// 路径、期限、网络或宿主响应无效时失败。 pub async fn upstream_http( &self, request: UpstreamHttpRequest, @@ -68,6 +69,7 @@ impl HostClient { host: self.clone(), stream: response.stream, upstream, + read: PendingRead::default(), }, }) } @@ -75,6 +77,7 @@ impl HostClient { impl HostHttpBody { /// 按需读取至多 64 KiB,EOF 后再次读取不再调用宿主。 + /// 取消一次等待后,再次调用会继续同一次读取,不丢弃分块。 /// /// # Errors /// 父调用结束、流无效或网络失败时返回错误。 @@ -89,20 +92,22 @@ impl HostHttpBody { } let maximum_bytes = u32::try_from(self.host.maximum_stream_chunk_bytes().min(64 * 1024)) .map_err(|_| invalid())?; - let (result, payload): (Read, _) = callback( - &self.host, - if self.upstream { - "host.upstream.http.stream_read" - } else { - "host.http.stream_read" - }, - wire::StreamRead { - stream: stream.clone(), - maximum_bytes, - }, - vec![], - ) - .await?; + let reply = self + .read + .run(|| { + let host = self.host.clone(); + let params = serde_json::json!({"stream": stream, "maximum_bytes": maximum_bytes}); + let method = if self.upstream { + "host.upstream.http.stream_read" + } else { + "host.http.stream_read" + }; + async move { host.call(method, params, vec![]).await } + }) + .await + .map_err(SessionError::into_plugin_fault)?; + let result: Read = serde_json::from_value(reply.result).map_err(|_| invalid())?; + let payload = reply.payload; if payload.len() > maximum_bytes as usize || (result.eof && !payload.is_empty()) { let _ = self.close().await; return Err(invalid()); @@ -162,6 +167,7 @@ impl HostHttpBody { ) .await?; self.stream = None; + self.read.clear(); Ok(()) } } diff --git a/backend/crates/gateway-plugin/sdk/src/client/keys.rs b/backend/crates/gateway-plugin/sdk/src/client/keys.rs index dde2ab350..28dd9a318 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/keys.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/keys.rs @@ -14,7 +14,7 @@ impl HostClient { /// 只读查询单个 Key 的预算,不开启或重置窗口。 /// /// # Errors - /// 未授权、阶段不符、Key 不存在或宿主读取失败时返回错误。 + /// Key 不存在或宿主读取失败时返回错误。 pub async fn get_key_budget( &self, request: key_budgets::GetKeyBudgetRequest, @@ -25,7 +25,7 @@ impl HostClient { /// 更新指定日/周上限;省略项不变,零表示不限,不清零用量。 /// /// # Errors - /// 未授权、阶段不符、参数无效、Key 不存在或宿主写入失败时返回错误。 + /// 参数无效、Key 不存在或宿主写入失败时返回错误。 pub async fn update_key_budget_limits( &self, request: key_budgets::UpdateKeyBudgetLimitsRequest, @@ -33,10 +33,10 @@ impl HostClient { payload_call(self, key_budgets::UPDATE_LIMITS, request).await } - /// 查询 Key 的非秘密身份;模型或预算访问域决定可用阶段。 + /// 查询 Key 的非秘密身份;调用与父资源保持关联。 /// /// # Errors - /// 权限、阶段或分页参数不合法,或者宿主读取失败时返回错误。 + /// 分页参数不合法,或者宿主读取失败时返回错误。 pub async fn list_keys(&self, query: KeyListRequest) -> Result { let invalid = || PluginFault::new(ErrorCode::InvalidInput, "invalid key list payload"); let reply = self @@ -56,7 +56,7 @@ impl HostClient { /// 清零指定周期,保留限额和到期时间;结果未知时不能盲目重试。 /// /// # Errors - /// 未授权、实例过期、Key 不存在、宿主写入失败时返回错误。 + /// 实例过期、Key 不存在、宿主写入失败时返回错误。 pub async fn reset_key_budget( &self, request: key_budgets::ResetKeyBudgetRequest, diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs index d724e65c8..7bf03d16f 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs @@ -3,8 +3,9 @@ use std::{collections::VecDeque, num::NonZeroUsize}; use crate::{ ErrorCode, PluginFault, call::middleware::{ - BODY_CLOSE_METHOD, BODY_READ_METHOD, MiddlewareBodyClose, MiddlewareBodyCloseResult, - MiddlewareBodyDisposition, MiddlewareBodyFrame, MiddlewareBodyFraming, + BODY_CLOSE_METHOD, BODY_FACTS_METHOD, BODY_READ_METHOD, MiddlewareBodyClose, + MiddlewareBodyCloseResult, MiddlewareBodyDisposition, MiddlewareBodyFacts, + MiddlewareBodyFactsResult, MiddlewareBodyFrame, MiddlewareBodyFraming, MiddlewareBodyHandle, MiddlewareBodyRead, MiddlewareBodyReadResult, MiddlewareResponseBody, }, }; @@ -13,6 +14,7 @@ use super::super::session::{ HostClient, PullResponseFuture, PullResponseStream, ResponseStream, SessionError, StreamSender, }; use super::invalid_input; +use crate::client::read::PendingRead; const MAXIMUM_MAPPED_FRAMES_PER_SOURCE: usize = 64; const MAXIMUM_MAPPED_BYTES_PER_SOURCE: usize = 8 * 1024 * 1024; @@ -28,7 +30,9 @@ enum MiddlewareBodySource { body: MiddlewareBodyHandle, host: HostClient, touched: bool, + include_facts: bool, last_source_id: u64, + read: PendingRead, PluginFault>>, }, Plugin { framing: MiddlewareBodyFraming, @@ -86,12 +90,15 @@ impl MiddlewareBody { /// 拉取下一完整 frame。调用后该 opaque handle 不能再作为未读直通返回。 /// 只读观察并原样交付时使用 [`Self::inspect_frames`],由 SDK 保留源帧。 + /// 取消等待后可继续同一次读取,包括尚未完成的事实快照读取。 pub async fn read(&mut self) -> Result, PluginFault> { let MiddlewareBodySource::Host { body, host, touched, + include_facts, last_source_id, + read, } = &mut self.source else { return Err(PluginFault::new( @@ -100,38 +107,24 @@ impl MiddlewareBody { )); }; *touched = true; - let maximum_bytes = - u32::try_from(host.maximum_stream_chunk_bytes()).map_err(|_| invalid_input())?; - let reply = host - .call( - BODY_READ_METHOD, - serde_json::to_value(MiddlewareBodyRead { - handle: body.handle.clone(), - maximum_bytes, - }) - .map_err(|_| invalid_input())?, - Vec::new(), - ) - .await - .map_err(SessionError::into_plugin_fault)?; - let result: MiddlewareBodyReadResult = - serde_json::from_value(reply.result).map_err(|_| invalid_input())?; - if result.framing != body.framing - || (result.eof && result.source_id != 0) - || (!result.eof && (result.source_id == 0 || result.source_id <= *last_source_id)) - { - return Err(invalid_input()); + let frame = read + .run(|| read_frame(body.clone(), host.clone(), *include_facts, *last_source_id)) + .await?; + if let Some(frame) = &frame { + *last_source_id = frame.source_id(); } - if result.eof { - if result.terminal || !reply.payload.is_empty() { - return Err(invalid_input()); - } - return Ok(None); + Ok(frame) + } + + /// 在读取或映射每个源帧时取得完整宿主事实,普通透传不产生额外快照开销。 + /// 必须在 `map_frames` / `inspect_frames` 前调用。 + pub fn with_facts(mut self) -> Result { + match &mut self.source { + MiddlewareBodySource::Host { include_facts, .. } => *include_facts = true, + MiddlewareBodySource::Empty => {} + MiddlewareBodySource::Plugin { .. } => return Err(invalid_input()), } - *last_source_id = result.source_id; - MiddlewareBodyFrame::from_source(reply.payload, result.terminal, result.source_id) - .map(Some) - .map_err(|_| invalid_input()) + Ok(self) } /// 提前关闭宿主正文句柄。父调用取消或结束也会由 Runtime 兜底回收。 @@ -216,7 +209,9 @@ impl MiddlewareBody { body, host, touched: false, + include_facts: false, last_source_id: 0, + read: PendingRead::default(), }, } } @@ -246,6 +241,70 @@ impl MiddlewareBody { } } +async fn read_frame( + body: MiddlewareBodyHandle, + host: HostClient, + include_facts: bool, + last_source_id: u64, +) -> Result, PluginFault> { + let maximum_bytes = + u32::try_from(host.maximum_stream_chunk_bytes()).map_err(|_| invalid_input())?; + let reply = host + .call( + BODY_READ_METHOD, + serde_json::to_value(MiddlewareBodyRead { + handle: body.handle.clone(), + maximum_bytes, + }) + .map_err(|_| invalid_input())?, + Vec::new(), + ) + .await + .map_err(SessionError::into_plugin_fault)?; + let result: MiddlewareBodyReadResult = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + if result.framing != body.framing + || (result.eof && result.source_id != 0) + || (!result.eof && (result.source_id == 0 || result.source_id <= last_source_id)) + { + return Err(invalid_input()); + } + if result.eof { + if result.terminal || !reply.payload.is_empty() { + return Err(invalid_input()); + } + return Ok(None); + } + let mut frame = + MiddlewareBodyFrame::from_source(reply.payload, result.terminal, result.source_id) + .map_err(|_| invalid_input())?; + if include_facts { + let reply = host + .call( + BODY_FACTS_METHOD, + serde_json::to_value(MiddlewareBodyFacts { + handle: body.handle.clone(), + source_id: result.source_id, + }) + .map_err(|_| invalid_input())?, + Vec::new(), + ) + .await + .map_err(SessionError::into_plugin_fault)?; + let result: MiddlewareBodyFactsResult = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + if result.present { + frame.facts = Some(Box::new( + crate::call::model::ExecutionEvent::decode(&reply.payload) + .map_err(|_| invalid_input())?, + )); + } else if !reply.payload.is_empty() { + return Err(invalid_input()); + } + } + Ok(Some(frame)) +} + struct MappedBody { input: MiddlewareBody, transform: F, diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/call/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/call/mod.rs new file mode 100644 index 000000000..4bc9bd60f --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/call/mod.rs @@ -0,0 +1,102 @@ +//! 多边界插件只注册一次处理器,按类型选择当前调用视图。 +use super::super::session::{CallReply, PluginCall}; +use super::{ + HttpCall, HttpResponse, MiddlewareInput, MiddlewareOutput, MiddlewareResponse, RequestCall, + ServiceCall, ServiceResponse, WebSocketCall, WebSocketMessage, +}; +use crate::{CallContext, PluginFault, Stage}; + +pub enum MiddlewareCall { + Service(Box), + Http(Box), + WebSocket(Box), + Request(Box), +} +pub enum MiddlewareResult { + Service(ServiceResponse), + Http(HttpResponse), + WebSocket(Option), + Request(MiddlewareResponse), +} +impl MiddlewareCall { + #[must_use] + pub fn context(&self) -> &CallContext { + match self { + Self::Service(call) => &call.context, + Self::Http(call) => &call.context, + Self::WebSocket(call) => &call.context, + Self::Request(call) => &call.context, + } + } + /// 原样进入下游,供当前处理器不感兴趣的调用复用。 + pub async fn forward(self) -> Result { + match self { + Self::Service(call) => call.forward().await.map(MiddlewareResult::Service), + Self::Http(call) => call + .next + .run(call.request) + .await + .map(MiddlewareResult::Http), + Self::WebSocket(call) => call + .next + .run(call.message) + .await + .map(MiddlewareResult::WebSocket), + Self::Request(call) => call + .next + .run(call.request) + .await + .map(MiddlewareResult::Request), + } + } +} +impl MiddlewareInput for MiddlewareCall { + type Output = MiddlewareResult; + fn accepts(stage: Stage) -> bool { + ServiceCall::accepts(stage) + || HttpCall::accepts(stage) + || WebSocketCall::accepts(stage) + || RequestCall::accepts(stage) + } + fn decode(call: PluginCall) -> Result { + match call.context.stage { + Stage::Service => ServiceCall::decode(call).map(Box::new).map(Self::Service), + Stage::Http => HttpCall::decode(call).map(Box::new).map(Self::Http), + Stage::WebSocket => WebSocketCall::decode(call) + .map(Box::new) + .map(Self::WebSocket), + _ => RequestCall::decode(call).map(Box::new).map(Self::Request), + } + } +} +impl MiddlewareOutput for MiddlewareResult { + fn encode(self) -> Result { + match self { + Self::Service(response) => response.encode(), + Self::Http(response) => response.encode(), + Self::WebSocket(response) => response.encode(), + Self::Request(response) => response.encode(), + } + } +} +impl From for MiddlewareResult { + fn from(response: HttpResponse) -> Self { + Self::Http(response) + } +} +impl From> for MiddlewareResult { + fn from(response: Option) -> Self { + Self::WebSocket(response) + } +} +impl From for MiddlewareResult { + fn from(response: MiddlewareResponse) -> Self { + Self::Request(response) + } +} + +impl From for MiddlewareResult { + fn from(response: ServiceResponse) -> Self { + Self::Service(response) + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/http/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/http/mod.rs new file mode 100644 index 000000000..47adeb213 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/http/mod.rs @@ -0,0 +1,613 @@ +use std::{collections::VecDeque, future::Future, pin::Pin}; + +use super::super::session::{ + CallCancellation, CallReply, HostClient, PluginCall, PullResponseFuture, PullResponseStream, + ResponseStream, SessionError, +}; +use super::{MiddlewareInput, MiddlewareOutput, invalid_input}; +use crate::{ + CallContext, PluginFault, Stage, + call::middleware::{ + HANDLE_METHOD, MiddlewareBodyClose, MiddlewareBodyRead, MiddlewareHeader, NEXT_METHOD, + http as wire, + }, + client::{HostReply, read::PendingRead}, +}; + +/// HTTP 数据帧与 trailers 分开表达;结束由流完成表示,不插入协议正文。 +pub enum HttpFrame { + Data(Vec), + Trailers(Vec), +} + +impl HttpFrame { + fn encode(self) -> Result, PluginFault> { + match self { + Self::Data(data) => { + let mut bytes = Vec::with_capacity(1 + data.len()); + bytes.push(0); + bytes.extend(data); + Ok(bytes) + } + Self::Trailers(headers) => { + let mut bytes = vec![1]; + serde_json::to_writer(&mut bytes, &headers).map_err(|_| invalid_input())?; + Ok(bytes) + } + } + } +} + +/// HTTP 正文惰性读取;透传直接返还句柄,完整收集由插件自行决定。 +pub struct HttpBody(Source); + +type Producer = Pin> + Send>>; + +enum Source { + Empty, + Bytes(Vec), + Host { + handle: String, + host: HostClient, + read: PendingRead>, + pending: VecDeque, + }, + Stream(ResponseStream), + Producing { + body: Box, + producer: Option, + }, +} + +impl HttpBody { + #[must_use] + pub const fn empty() -> Self { + Self(Source::Empty) + } + #[must_use] + pub fn from_bytes(bytes: Vec) -> Self { + Self(Source::Bytes(bytes)) + } + + pub async fn read(&mut self) -> Result, PluginFault> { + match &mut self.0 { + Source::Empty => return Ok(None), + Source::Bytes(bytes) => { + let bytes = std::mem::take(bytes); + self.0 = Source::Empty; + return Ok(Some(HttpFrame::Data(bytes))); + } + Source::Stream(stream) => { + return stream + .next() + .await + .transpose()? + .map(|bytes| match bytes.split_first() { + Some((0, data)) => Ok(HttpFrame::Data(data.to_vec())), + Some((1, data)) => serde_json::from_slice(data) + .map(HttpFrame::Trailers) + .map_err(|_| invalid_input()), + _ => Err(invalid_input()), + }) + .transpose(); + } + Source::Producing { body, producer } => { + let read = Box::pin(body.read()); + let Some(active) = producer.as_mut() else { + return read.await; + }; + tokio::pin!(read); + return tokio::select! { + biased; + frame = &mut read => frame, + produced = active => { produced?; producer.take(); read.await } + }; + } + Source::Host { .. } => {} + } + let Source::Host { + handle, + host, + read, + pending, + } = &mut self.0 + else { + return Err(invalid_input()); + }; + if !pending.is_empty() { + let count = pending + .len() + .min(host.maximum_stream_chunk_bytes().saturating_sub(1)); + if count == 0 { + return Err(invalid_input()); + } + return Ok(Some(HttpFrame::Data(pending.drain(..count).collect()))); + } + let reply = read + .run(|| { + let host = host.clone(); + let handle = handle.clone(); + async move { + let maximum_bytes = + u32::try_from(host.maximum_stream_chunk_bytes().saturating_sub(1)) + .map_err(|_| SessionError::Protocol)?; + host.call( + wire::BODY_READ_METHOD, + serde_json::to_value(MiddlewareBodyRead { + handle, + maximum_bytes, + }) + .map_err(|_| SessionError::Protocol)?, + Vec::new(), + ) + .await + } + }) + .await + .map_err(SessionError::into_plugin_fault)?; + let read: wire::BodyRead = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + if read.eof { + if !reply.payload.is_empty() || read.trailers.is_some() { + return Err(invalid_input()); + } + self.0 = Source::Empty; + return Ok(None); + } + match read.trailers { + Some(headers) if reply.payload.is_empty() => Ok(Some(HttpFrame::Trailers(headers))), + Some(_) => Err(invalid_input()), + None => { + // 读取可能早于初始 Credit 发出;返回路径仍须遵守实际窗口,并为帧标记留一字节。 + let maximum = host.maximum_stream_chunk_bytes().saturating_sub(1); + if maximum == 0 { + return Err(invalid_input()); + } + if reply.payload.len() <= maximum { + Ok(Some(HttpFrame::Data(reply.payload))) + } else { + *pending = reply.payload.into(); + Ok(Some(HttpFrame::Data(pending.drain(..maximum).collect()))) + } + } + } + } + + pub async fn close(&mut self) -> Result<(), PluginFault> { + if let Source::Producing { body, .. } = &mut self.0 { + Box::pin(body.close()).await?; + } else if let Source::Host { handle, host, .. } = &self.0 { + let reply = host + .call( + wire::BODY_CLOSE_METHOD, + serde_json::to_value(MiddlewareBodyClose { + handle: handle.clone(), + }) + .map_err(|_| invalid_input())?, + Vec::new(), + ) + .await + .map_err(SessionError::into_plugin_fault)?; + if reply.result != serde_json::json!({}) || !reply.payload.is_empty() { + return Err(invalid_input()); + } + } + self.0 = Source::Empty; + Ok(()) + } + + /// 按输出背压逐帧转换;None 丢弃一帧。需要展开时可使用自定义 ResponseStream。 + pub fn map_frames(self, transform: F) -> Self + where + F: FnMut(HttpFrame) -> Result, PluginFault> + Send + 'static, + { + Self(Source::Stream(ResponseStream::pull(Box::new(MappedBody { + body: self, + transform, + })))) + } + + fn from_wire(body: wire::Body, host: HostClient) -> Result { + match body { + wire::Body::Empty => Ok(Self::empty()), + wire::Body::Handle { handle } if !handle.is_empty() => Ok(Self(Source::Host { + handle, + host, + read: PendingRead::default(), + pending: VecDeque::new(), + })), + _ => Err(invalid_input()), + } + } + + fn into_wire(self) -> (wire::Body, Vec, Option) { + match self.0 { + Source::Empty => (wire::Body::Empty, Vec::new(), None), + Source::Bytes(bytes) => (wire::Body::Bytes, bytes, None), + Source::Host { + handle, + host, + read, + pending, + } => { + if read.is_pending() || !pending.is_empty() { + ( + wire::Body::Stream, + Vec::new(), + Some(ResponseStream::pull(Box::new(ForwardBody(Self( + Source::Host { + handle, + host, + read, + pending, + }, + ))))), + ) + } else { + (wire::Body::Handle { handle }, Vec::new(), None) + } + } + Source::Stream(stream) => (wire::Body::Stream, Vec::new(), Some(stream)), + Source::Producing { body, producer } => ( + wire::Body::Stream, + Vec::new(), + Some(ResponseStream::pull(Box::new(ForwardBody(HttpBody( + Source::Producing { body, producer }, + ))))), + ), + } + } +} + +struct ForwardBody(HttpBody); +impl PullResponseStream for ForwardBody { + fn next(&mut self) -> PullResponseFuture<'_> { + Box::pin(async { + self.0 + .read() + .await + .and_then(|frame| frame.map(HttpFrame::encode).transpose()) + .transpose() + }) + } +} + +async fn produce( + mut stream: ResponseStream, + host: HostClient, + handle: String, +) -> Result<(), PluginFault> { + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + let (trailers, payload) = match chunk.split_first() { + Some((0, data)) => (None, data.to_vec()), + Some((1, data)) => ( + Some(serde_json::from_slice(data).map_err(|_| invalid_input())?), + Vec::new(), + ), + _ => return Err(invalid_input()), + }; + if !write_body( + &host, + wire::BodyWrite { + handle: handle.clone(), + eof: false, + trailers, + }, + payload, + ) + .await? + { + return Ok(()); + } + } + write_body( + &host, + wire::BodyWrite { + handle, + eof: true, + trailers: None, + }, + Vec::new(), + ) + .await?; + Ok(()) +} + +async fn write_body( + host: &HostClient, + write: wire::BodyWrite, + payload: Vec, +) -> Result { + match host + .call( + wire::BODY_WRITE_METHOD, + serde_json::to_value(write).map_err(|_| invalid_input())?, + payload, + ) + .await + { + Ok(reply) if reply.result == serde_json::json!({}) && reply.payload.is_empty() => Ok(true), + // 下游允许在读取完整请求前返回,例如拒绝认证或直接响应。 + Err(SessionError::Remote(fault)) if fault.code == crate::ErrorCode::Conflict => Ok(false), + Err(error) => Err(error.into_plugin_fault()), + _ => Err(invalid_input()), + } +} + +struct MappedBody { + body: HttpBody, + transform: F, +} +impl PullResponseStream for MappedBody +where + F: FnMut(HttpFrame) -> Result, PluginFault> + Send + 'static, +{ + fn next(&mut self) -> PullResponseFuture<'_> { + Box::pin(async move { + async { + while let Some(frame) = self.body.read().await? { + if let Some(frame) = (self.transform)(frame)? { + return frame.encode().map(Some); + } + } + Ok(None) + } + .await + .transpose() + }) + } +} + +pub struct HttpRequest { + pub settings: serde_json::Value, + pub method: String, + pub uri: String, + pub version: wire::Version, + pub headers: Vec, + pub timeout_ms: Option, + pub body: HttpBody, +} + +pub struct HttpNext { + host: HostClient, +} +impl HttpNext { + pub async fn run(self, request: HttpRequest) -> Result { + send_request(&self.host, NEXT_METHOD, request).await + } +} + +impl HostClient { + /// 主动调用宿主相对路径,经过完整 HTTP 洋葱链并继承父调用的取消与防递归上下文。 + pub async fn dispatch_http(&self, request: HttpRequest) -> Result { + send_request(self, wire::DISPATCH_METHOD, request).await + } +} + +async fn send_request( + host: &HostClient, + method: &str, + request: HttpRequest, +) -> Result { + let (mut body, payload, stream) = request.body.into_wire(); + let mut producer: Option = if let Some(stream) = stream { + let reply = host + .call(wire::BODY_CREATE_METHOD, serde_json::json!({}), Vec::new()) + .await + .map_err(SessionError::into_plugin_fault)?; + if !reply.payload.is_empty() { + return Err(invalid_input()); + } + let pipe: wire::BodyPipe = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + body = wire::Body::Handle { + handle: pipe.handle.clone(), + }; + Some(Box::pin(produce(stream, host.clone(), pipe.handle))) + } else { + None + }; + let request = wire::Request { + settings: request.settings, + method: request.method, + uri: request.uri, + version: request.version, + headers: request.headers, + timeout_ms: request.timeout_ms, + body, + }; + let params = serde_json::to_value(request).map_err(|_| invalid_input())?; + let next = host.call(method, params, payload); + tokio::pin!(next); + let reply = if let Some(active) = producer.as_mut() { + tokio::select! { + biased; + result = &mut next => result, + produced = active => { produced?; producer.take(); next.await } + } + } else { + next.await + } + .map_err(SessionError::into_plugin_fault)?; + if !reply.payload.is_empty() { + return Err(invalid_input()); + } + let response: wire::Response = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + Ok(HttpResponse { + status: response.status, + version: response.version, + headers: response.headers, + body: { + let body = HttpBody::from_wire(response.body, host.clone())?; + if producer.is_some() { + HttpBody(Source::Producing { + body: Box::new(body), + producer, + }) + } else { + body + } + }, + source: response.response, + session: None, + }) +} + +pub struct HttpResponse { + pub status: u16, + pub version: wire::Version, + pub headers: Vec, + pub body: HttpBody, + source: Option, + session: Option, +} + +impl HttpResponse { + #[must_use] + pub fn new(status: u16, body: HttpBody) -> Self { + Self { + status, + version: wire::Version::Http11, + headers: Vec::new(), + body, + source: None, + session: None, + } + } + + pub fn into_reply(self) -> Result { + let (body, payload, stream) = self.body.into_wire(); + let response = wire::Response { + status: self.status, + version: self.version, + headers: self.headers, + body, + response: self.source, + session: self.session.is_some(), + }; + Ok(CallReply::stream( + serde_json::to_value(response).map_err(|_| invalid_input())?, + payload, + self.session + .map(|task| ResponseStream::pull(Box::new(SessionTask(Some(task))))) + .or(stream) + .unwrap_or_else(|| ResponseStream::from_chunks(Vec::new())), + )) + } +} + +pub struct HttpCall { + pub settings_sources: serde_json::Value, + pub call_id: String, + pub parent_call_id: Option, + pub context: CallContext, + pub request: HttpRequest, + pub next: HttpNext, + pub host: HostClient, + pub cancellation: CallCancellation, +} + +struct SessionTask(Option); +impl PullResponseStream for SessionTask { + fn next(&mut self) -> PullResponseFuture<'_> { + Box::pin(async move { self.0.take()?.await.err().map(Err) }) + } +} + +impl HttpCall { + /// 在当前 HTTP 返回路径选择握手;处理函数随真实连接运行,完成或失败时回收连接。 + pub async fn upgrade( + self, + protocols: Vec, + handler: F, + ) -> Result + where + F: FnOnce(super::websocket::WebSocketSession) -> Fut, + Fut: Future> + Send + 'static, + { + let request = wire::Request { + settings: self.request.settings, + method: self.request.method, + uri: self.request.uri, + version: self.request.version, + headers: self.request.headers, + timeout_ms: self.request.timeout_ms, + body: wire::Body::Empty, + }; + let reply = self + .host + .call( + wire::UPGRADE_METHOD, + serde_json::to_value(wire::Upgrade { request, protocols }) + .map_err(|_| invalid_input())?, + Vec::new(), + ) + .await + .map_err(SessionError::into_plugin_fault)?; + if !reply.payload.is_empty() { + return Err(invalid_input()); + } + let response: wire::Response = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + let session = super::websocket::WebSocketSession::new(self.host, self.cancellation); + Ok(HttpResponse { + status: response.status, + version: response.version, + headers: response.headers, + body: HttpBody::empty(), + source: response.response, + session: Some(Box::pin(handler(session))), + }) + } + + pub fn try_from(call: PluginCall) -> Result { + if call.method != HANDLE_METHOD + || call.context.stage != Stage::Http + || !call.payload.is_empty() + { + return Err(invalid_input()); + } + let input: wire::Call = serde_json::from_value(call.params).map_err(|_| invalid_input())?; + if call.context.request_id.as_ref() != Some(&input.request_id) { + return Err(invalid_input()); + } + let request = input.request; + Ok(Self { + settings_sources: input.settings_sources, + call_id: input.call_id, + parent_call_id: input.parent_call_id, + context: call.context, + cancellation: call.cancellation, + request: HttpRequest { + settings: request.settings, + method: request.method, + uri: request.uri, + version: request.version, + headers: request.headers, + timeout_ms: request.timeout_ms, + body: HttpBody::from_wire(request.body, call.host.clone())?, + }, + next: HttpNext { + host: call.host.clone(), + }, + host: call.host, + }) + } +} + +impl MiddlewareInput for HttpCall { + type Output = HttpResponse; + fn accepts(stage: Stage) -> bool { + stage == Stage::Http + } + fn decode(call: PluginCall) -> Result { + Self::try_from(call) + } +} +impl MiddlewareOutput for HttpResponse { + fn encode(self) -> Result { + self.into_reply() + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs index 0129f19e4..87d0e429a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs @@ -1,5 +1,16 @@ +mod service; +pub use service::{ServiceCall, ServiceNext, ServiceResponse, TypedServiceCall}; mod body; +mod call; +pub use call::{MiddlewareCall, MiddlewareResult}; +mod http; +mod websocket; +pub use websocket::{ + WebSocketCall, WebSocketDirection, WebSocketKind, WebSocketMessage, WebSocketNext, + WebSocketPayload, WebSocketSender, WebSocketSession, +}; mod plugin; +pub use http::{HttpBody, HttpCall, HttpFrame, HttpNext, HttpRequest, HttpResponse}; pub use body::{MiddlewareBody, MiddlewareBodySender}; pub use plugin::MiddlewarePlugin; @@ -15,24 +26,24 @@ use crate::{ use super::session::{CallCancellation, CallReply, HostClient, PluginCall, SessionError}; -/// 作者可读取并按授权修改的一次中间件请求。 +/// 作者可读取并修改的一次中间件请求。 /// -/// 直接修改公开的 `head`/`body` 会由 [`MiddlewareNext::run`] 与原投影比较; +/// 直接修改公开的 `head`/`body` 会由 [`MiddlewareNext::run`] 与原始输入比较; /// `replace_body` 还可明确表达“替换为空正文”。 pub struct MiddlewareRequest { pub head: MiddlewareRequestHead, pub body: Vec, original_protocol: String, + original_settings: serde_json::Value, original_headers: Vec, original_body: Vec, - body_visible: bool, body_replaced: bool, header_mutations: Vec, capabilities: Option, } impl MiddlewareRequest { - /// 仅 middleware v2 的 request 阶段可声明转换;须同时替换正文并负责响应还原。 + /// 仅 request 阶段可声明转换;须同时替换正文并负责响应还原。 pub fn declare_capabilities( &mut self, declaration: crate::call::middleware::CapabilityDeclaration, @@ -74,9 +85,7 @@ impl MiddlewareRequest { self.header_mutations, self.head.headers, ); - let changed_body = self.body_replaced - || self.body != self.original_body - || (!self.body_visible && !self.body.is_empty()); + let changed_body = self.body_replaced || self.body != self.original_body; let (body, payload) = if changed_body { (MiddlewareRequestBody::Replace, self.body) } else { @@ -84,6 +93,8 @@ impl MiddlewareRequest { }; ( MiddlewareNextRequest { + settings: (self.head.settings != self.original_settings) + .then_some(self.head.settings), protocol, header_mutations, body, @@ -175,8 +186,9 @@ impl MiddlewareNext { } } -/// 作者返回的响应;原 next 响应头以增量方式修改,隐藏字段仍由 Runtime 保存。 +/// 作者返回的响应;完整 next 响应头以增量方式修改,未改写的字节保持原样。 pub struct MiddlewareResponse { + pub metadata: Option>, pub protocol: String, pub status: u16, pub headers: Vec, @@ -204,6 +216,7 @@ impl MiddlewareResponse { MiddlewareBody::from_host(body, host) }); Ok(Self { + metadata: response.metadata, protocol: response.protocol.clone(), status: response.status, headers: response.headers.clone(), @@ -225,6 +238,7 @@ impl MiddlewareResponse { body: MiddlewareBody, ) -> Self { Self { + metadata: None, protocol: protocol.into(), status, headers, @@ -279,7 +293,7 @@ impl MiddlewareResponse { } /// 交给中间件作者的一次真实调用。 -pub struct MiddlewareCall { +pub struct RequestCall { pub context: CallContext, pub request: MiddlewareRequest, pub next: MiddlewareNext, @@ -287,7 +301,7 @@ pub struct MiddlewareCall { pub host: HostClient, } -impl MiddlewareCall { +impl RequestCall { /// 从 `middleware.handle` RPC 解码并交叉检查宿主签发的 mount/身份。 pub fn try_from(call: PluginCall) -> Result { if call.method != HANDLE_METHOD { @@ -309,7 +323,6 @@ impl MiddlewareCall { || (mount == MiddlewareMount::Request && head.attempt_index.is_some()) || (mount == MiddlewareMount::Attempt && head.attempt_index.is_none_or(|attempt| attempt == 0)) - || !head.body_visible && !call.payload.is_empty() { return Err(invalid_input()); } @@ -320,9 +333,9 @@ impl MiddlewareCall { Ok(Self { context: call.context, request: MiddlewareRequest { + original_settings: head.settings.clone(), body: call.payload, original_body, - body_visible: head.body_visible, body_replaced: false, original_protocol, original_headers, @@ -340,3 +353,29 @@ impl MiddlewareCall { fn invalid_input() -> PluginFault { PluginFault::new(ErrorCode::InvalidInput, "middleware input is invalid") } + +/// 不同公开边界提供类型化视图,注册与组合仍使用同一个 middleware 函数。 +pub trait MiddlewareInput: Sized + Send + 'static { + type Output: MiddlewareOutput; + fn accepts(stage: Stage) -> bool; + fn decode(call: PluginCall) -> Result; +} + +pub trait MiddlewareOutput: Send + 'static { + fn encode(self) -> Result; +} + +impl MiddlewareInput for RequestCall { + type Output = MiddlewareResponse; + fn accepts(stage: Stage) -> bool { + matches!(stage, Stage::Request | Stage::Attempt) + } + fn decode(call: PluginCall) -> Result { + Self::try_from(call) + } +} +impl MiddlewareOutput for MiddlewareResponse { + fn encode(self) -> Result { + self.into_reply() + } +} 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 820a291f5..e3acfea4b 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs @@ -7,79 +7,96 @@ use crate::{ use super::{ super::session::{CallFuture, CallReply, PluginCall, PluginHandler, SessionError}, - MiddlewareCall, MiddlewareResponse, + MiddlewareInput, MiddlewareOutput, RequestCall, }; /// 单个中间件插件的作者入口;自动注册并将 RPC 分派到类型化业务函数。 /// /// 传入作者清单的完整能力列表,不从宿主握手照抄未实现的能力。此入口仅承载 /// 一个 middleware 处理器;复合插件仍使用 [`PluginHandler`],不能虚报其他能力。 -/// 会话、授权和生命周期分别由既有 SDK 会话与宿主负责,不在此建立第二套状态。 +/// 会话和生命周期分别由既有 SDK 会话与宿主负责,不在此建立第二套状态。 /// /// # Examples /// /// ``` /// use gateway_plugin_sdk::{Capability, ContributionDeclaration, Contributions, Stage}; -/// use gateway_plugin_sdk::client::{MiddlewareCall, MiddlewarePlugin}; +/// use gateway_plugin_sdk::client::{RequestCall, MiddlewarePlugin}; /// /// let contributes = Contributions::from([(Capability::Middleware, ContributionDeclaration { /// id: "acme.request-tags.tagRequest".into(), -/// version: 1, +/// version: 3, /// stages: vec![Stage::Request], /// input_formats: vec!["openai".into()], /// output_formats: vec!["openai".into()], /// })]); -/// let plugin = MiddlewarePlugin::new(&contributes, |call: MiddlewareCall| async move { -/// let MiddlewareCall { mut request, next, .. } = call; +/// let plugin = MiddlewarePlugin::new(&contributes, |call: RequestCall| async move { +/// let RequestCall { mut request, next, .. } = call; /// request.append_header("x-team", b"research".to_vec()); /// next.run(request).await /// })?; -/// // 将 plugin 交给 PluginSession::run;请求头修改仍需管理员授权。 +/// // 将 plugin 交给 PluginSession::run;请求头修改直接作用于当前调用。 /// # Ok::<(), gateway_plugin_sdk::client::SessionError>(()) /// ``` -pub struct MiddlewarePlugin { +pub struct MiddlewarePlugin { + input: std::marker::PhantomData, contributes: Contributions, handler: F, } -impl MiddlewarePlugin +impl MiddlewarePlugin where - F: Fn(MiddlewareCall) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, + C: MiddlewareInput, + F: Fn(C) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, { /// 绑定清单中唯一的中间件声明与业务处理函数。 /// /// # Errors /// /// 空声明、其他能力、重复能力、不支持的能力版本或挂载阶段返回配置错误。 - /// 包格式、资源和权限仍由宿主校验;此处不根据代码推断或扩张授权。 + /// 包格式与资源生命周期由宿主校验;入口不根据处理器代码推断清单。 pub fn new(contributes: &Contributions, handler: F) -> Result { let Some(declaration) = contributes.get(&Capability::Middleware) else { return Err(SessionError::Configuration); }; if contributes.len() != 1 - || !matches!(declaration.version, 1 | 2) + || !Capability::Middleware + .contract_versions() + .contains(&declaration.version) || declaration.stages.is_empty() - || declaration.stages.len() > 2 + || declaration.stages.len() > 5 + || declaration.stages.iter().any(|stage| { + !matches!( + stage, + Stage::Http + | Stage::WebSocket + | Stage::Service + | Stage::Request + | Stage::Attempt + ) || !C::accepts(*stage) + }) || declaration .stages .iter() - .any(|stage| !matches!(stage, Stage::Request | Stage::Attempt)) - || (declaration.stages.len() == 2 && declaration.stages[0] == declaration.stages[1]) + .collect::>() + .len() + != declaration.stages.len() { return Err(SessionError::Configuration); } Ok(Self { + input: std::marker::PhantomData, contributes: contributes.clone(), handler, }) } } -impl PluginHandler for MiddlewarePlugin +impl PluginHandler for MiddlewarePlugin where - F: Fn(MiddlewareCall) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, + C: MiddlewareInput, + F: Fn(C) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, { fn call(&self, call: PluginCall) -> CallFuture<'_> { Box::pin(async move { @@ -111,8 +128,8 @@ where if !declaration.stages.contains(&call.context.stage) { return Err(super::invalid_input()); } - let call = MiddlewareCall::try_from(call)?; - (self.handler)(call).await?.into_reply() + let call = C::decode(call)?; + (self.handler)(call).await?.encode() } _ => Err(PluginFault::new( ErrorCode::Unsupported, diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/service/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/service/mod.rs new file mode 100644 index 000000000..67eccfb93 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/service/mod.rs @@ -0,0 +1,169 @@ +//! 公开服务中间件;Operation 关联输入输出类型,无需逐操作增加 hook。 + +use super::{MiddlewareInput, MiddlewareOutput, invalid_input}; +use crate::client::session::{CallCancellation, CallReply, HostClient, PluginCall}; +use crate::{ + CallContext, PluginFault, Stage, + call::{ + middleware::NEXT_METHOD, + services::{self as wire, Operation, ServiceError}, + }, +}; +use serde_json::Value; +use std::marker::PhantomData; + +pub struct ServiceCall { + pub context: CallContext, + pub operation: String, + pub call_id: String, + pub parent_call_id: Option, + pub request_id: String, + input: Value, + host: HostClient, + pub cancellation: CallCancellation, +} + +pub struct TypedServiceCall { + pub context: CallContext, + pub call_id: String, + pub parent_call_id: Option, + pub request_id: String, + pub input: O::Input, + pub next: ServiceNext, + pub host: HostClient, + pub cancellation: CallCancellation, +} + +pub struct ServiceNext { + host: HostClient, + marker: PhantomData, +} + +pub struct ServiceResponse(wire::Response); + +impl ServiceCall { + /// 不消费调用即可匹配其公开合同,再取得类型化视图。 + #[must_use] + pub fn is(&self) -> bool { + self.operation == O::NAME + } + + pub fn into_typed(self) -> Result, PluginFault> { + if !self.is::() { + return Err(invalid_input()); + } + Ok(TypedServiceCall { + input: serde_json::from_value(self.input).map_err(|_| invalid_input())?, + next: ServiceNext { + host: self.host.clone(), + marker: PhantomData, + }, + host: self.host, + context: self.context, + call_id: self.call_id, + parent_call_id: self.parent_call_id, + request_id: self.request_id, + cancellation: self.cancellation, + }) + } + + pub async fn forward(self) -> Result { + let reply = self + .host + .call(NEXT_METHOD, self.input, Vec::new()) + .await + .map_err(crate::client::session::SessionError::into_plugin_fault)?; + if !reply.payload.is_empty() { + return Err(invalid_input()); + } + serde_json::from_value(reply.result) + .map(ServiceResponse) + .map_err(|_| invalid_input()) + } +} + +impl ServiceNext { + pub async fn run(self, input: O::Input) -> Result { + let input = serde_json::to_value(input).map_err(|_| invalid())?; + decode(self.host.call(NEXT_METHOD, input, Vec::new()).await) + } +} + +impl ServiceResponse { + pub fn from_result( + result: Result, + ) -> Result { + Ok(Self(match result { + Ok(output) => Ok(serde_json::to_value(output).map_err(|_| invalid_input())?), + Err(error) => Err(error), + })) + } +} + +impl HostClient { + pub async fn service(&self, input: O::Input) -> Result { + let params = serde_json::to_value(wire::Request { + operation: O::NAME.into(), + input: serde_json::to_value(input).map_err(|_| invalid())?, + }) + .map_err(|_| invalid())?; + decode(self.call(wire::CALL_METHOD, params, Vec::new()).await) + } +} + +fn decode( + reply: Result, +) -> Result { + let reply = reply.map_err(|error| { + let fault = error.into_plugin_fault(); + ServiceError { + kind: "unavailable".into(), + message: fault.message.clone(), + details: Some(serde_json::json!(fault)), + } + })?; + if !reply.payload.is_empty() { + return Err(invalid()); + } + let result: wire::Response = serde_json::from_value(reply.result).map_err(|_| invalid())?; + serde_json::from_value(result?).map_err(|_| invalid()) +} + +fn invalid() -> ServiceError { + ServiceError { + kind: "invalid".into(), + message: "公开服务输入或结果类型无效".into(), + details: None, + } +} + +impl MiddlewareInput for ServiceCall { + type Output = ServiceResponse; + fn accepts(stage: Stage) -> bool { + stage == Stage::Service + } + fn decode(call: PluginCall) -> Result { + if !Self::accepts(call.context.stage) || !call.payload.is_empty() { + return Err(invalid_input()); + } + let input: wire::Call = serde_json::from_value(call.params).map_err(|_| invalid_input())?; + Ok(Self { + context: call.context, + operation: input.operation, + input: input.input, + call_id: input.call_id, + parent_call_id: input.parent_call_id, + request_id: input.request_id, + host: call.host, + cancellation: call.cancellation, + }) + } +} +impl MiddlewareOutput for ServiceResponse { + fn encode(self) -> Result { + Ok(CallReply::unary( + serde_json::to_value(self.0).map_err(|_| invalid_input())?, + Vec::new(), + )) + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket/mod.rs new file mode 100644 index 000000000..41fb5e2dd --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/websocket/mod.rs @@ -0,0 +1,309 @@ +use super::super::session::{CallCancellation, CallReply, HostClient, PluginCall, SessionError}; +use super::{MiddlewareInput, MiddlewareOutput, invalid_input}; +use crate::client::{HostReply, read::PendingRead}; +use crate::{ + CallContext, ErrorCode, PluginFault, Stage, + call::middleware::{ + BODY_READ_METHOD, HANDLE_METHOD, MiddlewareBodyRead, MiddlewareHeader, NEXT_METHOD, + websocket as wire, + }, +}; +pub use wire::{Direction as WebSocketDirection, Kind as WebSocketKind}; + +/// 原始消息正文按需读取;直接传给 next 或返回时不读取、不复制。 +pub struct WebSocketPayload { + source: wire::Payload, + bytes: Vec, + host: Option, + read: PendingRead>, +} +impl WebSocketPayload { + #[must_use] + pub fn from_bytes(bytes: Vec) -> Self { + Self { + source: wire::Payload::Bytes, + bytes, + host: None, + read: PendingRead::default(), + } + } + pub async fn read(&mut self) -> Result>, PluginFault> { + let wire::Payload::Handle { handle } = &self.source else { + return if self.bytes.is_empty() { + Ok(None) + } else { + Ok(Some(std::mem::take(&mut self.bytes))) + }; + }; + let host = self.host.as_ref().ok_or_else(invalid_input)?; + let reply = self + .read + .run(|| { + let host = host.clone(); + let handle = handle.clone(); + async move { + host.call( + BODY_READ_METHOD, + serde_json::to_value(MiddlewareBodyRead { + handle, + maximum_bytes: u32::try_from(host.maximum_stream_chunk_bytes()) + .map_err(|_| SessionError::Protocol)?, + }) + .map_err(|_| SessionError::Protocol)?, + Vec::new(), + ) + .await + } + }) + .await + .map_err(SessionError::into_plugin_fault)?; + let read: wire::Read = serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + if read.eof { + if !reply.payload.is_empty() { + return Err(invalid_input()); + } + self.source = wire::Payload::Bytes; + Ok(None) + } else { + Ok(Some(reply.payload)) + } + } + /// 不再需要正文时释放句柄;会话结束也会回收所有未释放资源。 + pub async fn close(&mut self) -> Result<(), PluginFault> { + if let wire::Payload::Handle { handle } = &self.source { + let host = self.host.as_ref().ok_or_else(invalid_input)?; + let reply = host + .call( + crate::call::middleware::BODY_CLOSE_METHOD, + serde_json::to_value(crate::call::middleware::MiddlewareBodyClose { + handle: handle.clone(), + }) + .map_err(|_| invalid_input())?, + Vec::new(), + ) + .await + .map_err(SessionError::into_plugin_fault)?; + if reply.result != serde_json::json!({}) || !reply.payload.is_empty() { + return Err(invalid_input()); + } + } + self.source = wire::Payload::Bytes; + self.bytes.clear(); + self.read.clear(); + Ok(()) + } + /// 收集大小由插件决定;透传不需要调用此方法。 + pub async fn collect(mut self) -> Result, PluginFault> { + let mut bytes = Vec::new(); + while let Some(chunk) = self.read().await? { + bytes.extend(chunk); + } + Ok(bytes) + } +} +pub struct WebSocketMessage { + pub kind: WebSocketKind, + pub payload: WebSocketPayload, +} +impl WebSocketMessage { + #[must_use] + pub fn new(kind: WebSocketKind, bytes: Vec) -> Self { + Self { + kind, + payload: WebSocketPayload::from_bytes(bytes), + } + } + pub(crate) fn decode( + message: wire::Message, + bytes: Vec, + host: HostClient, + ) -> Result { + if matches!(message.payload, wire::Payload::Handle { .. }) && !bytes.is_empty() { + return Err(invalid_input()); + } + Ok(Self { + kind: message.kind, + payload: WebSocketPayload { + source: message.payload, + bytes, + host: Some(host), + read: PendingRead::default(), + }, + }) + } + fn into_wire(self) -> Result<(wire::Message, Vec), PluginFault> { + // 尚在读取的消息不能直接归还句柄,否则宿主稍后返回的数据会被丢弃。 + if self.payload.read.is_pending() { + return Err(PluginFault::new( + ErrorCode::InvalidInput, + "a pending WebSocket payload read must be resumed or closed before transfer", + )); + } + Ok(( + wire::Message { + kind: self.kind, + payload: self.payload.source, + }, + self.payload.bytes, + )) + } +} +pub struct WebSocketNext { + host: HostClient, +} +impl WebSocketNext { + pub async fn run( + self, + message: WebSocketMessage, + ) -> Result, PluginFault> { + let (head, bytes) = message.into_wire()?; + let reply = self + .host + .call( + NEXT_METHOD, + serde_json::to_value(head).map_err(|_| invalid_input())?, + bytes, + ) + .await + .map_err(SessionError::into_plugin_fault)?; + let result: Option = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + match result { + Some(message) => WebSocketMessage::decode(message, reply.payload, self.host).map(Some), + None if reply.payload.is_empty() => Ok(None), + None => Err(invalid_input()), + } + } +} +#[derive(Clone)] +pub struct WebSocketSender { + host: HostClient, +} +impl WebSocketSender { + pub(crate) fn new(host: HostClient) -> Self { + Self { host } + } + /// 在当前连接直接发送消息并等待实际写入,不伪装成默认协议的输出。 + pub async fn send(&self, message: WebSocketMessage) -> Result<(), PluginFault> { + let (head, bytes) = message.into_wire()?; + let reply = self + .host + .call( + wire::SEND_METHOD, + serde_json::to_value(head).map_err(|_| invalid_input())?, + bytes, + ) + .await + .map_err(SessionError::into_plugin_fault)?; + if reply.result != serde_json::json!({}) || !reply.payload.is_empty() { + return Err(invalid_input()); + } + Ok(()) + } +} +pub struct WebSocketCall { + pub context: CallContext, + pub connection_id: String, + pub direction: WebSocketDirection, + pub headers: Vec, + pub message: WebSocketMessage, + pub next: WebSocketNext, + pub sender: WebSocketSender, + pub host: HostClient, + pub cancellation: CallCancellation, +} +impl MiddlewareInput for WebSocketCall { + type Output = Option; + fn accepts(stage: Stage) -> bool { + stage == Stage::WebSocket + } + fn decode(call: PluginCall) -> Result { + if call.method != HANDLE_METHOD || !Self::accepts(call.context.stage) { + return Err(invalid_input()); + } + let input: wire::Call = serde_json::from_value(call.params).map_err(|_| invalid_input())?; + Ok(Self { + context: call.context, + connection_id: input.connection_id, + direction: input.direction, + headers: input.headers, + message: WebSocketMessage::decode(input.message, call.payload, call.host.clone())?, + next: WebSocketNext { + host: call.host.clone(), + }, + sender: WebSocketSender { + host: call.host.clone(), + }, + host: call.host, + cancellation: call.cancellation, + }) + } +} +impl MiddlewareOutput for Option { + fn encode(self) -> Result { + let (head, payload) = match self { + Some(message) => { + let (head, payload) = message.into_wire()?; + (Some(head), payload) + } + None => (None, Vec::new()), + }; + Ok(CallReply::unary( + serde_json::to_value(head).map_err(|_| invalid_input())?, + payload, + )) + } +} + +/// 由 HTTP 中间件接管的连接;接收权独占,发送端可以复制后并发使用。 +pub struct WebSocketSession { + pub sender: WebSocketSender, + pub cancellation: CallCancellation, + host: HostClient, + receive: PendingRead>, +} + +impl WebSocketSession { + pub(crate) fn new(host: HostClient, cancellation: CallCancellation) -> Self { + Self { + sender: WebSocketSender::new(host.clone()), + cancellation, + host, + receive: PendingRead::default(), + } + } + pub async fn receive(&mut self) -> Result, PluginFault> { + let reply = self + .receive + .run(|| { + let host = self.host.clone(); + async move { + host.call( + crate::call::middleware::http::RECEIVE_METHOD, + serde_json::json!({}), + Vec::new(), + ) + .await + } + }) + .await + .map_err(SessionError::into_plugin_fault)?; + let message: Option = + serde_json::from_value(reply.result).map_err(|_| invalid_input())?; + match message { + Some(message) => { + WebSocketMessage::decode(message, reply.payload, self.host.clone()).map(Some) + } + None if reply.payload.is_empty() => Ok(None), + None => Err(invalid_input()), + } + } + pub async fn close(self, code: Option, reason: String) -> Result<(), PluginFault> { + self.sender + .send(WebSocketMessage::new( + WebSocketKind::Close { code }, + reason.into_bytes(), + )) + .await + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/mod.rs index 289f7ad9f..a21bb737e 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/mod.rs @@ -6,6 +6,7 @@ mod http; mod keys; mod middleware; mod plugin; +mod read; mod resources; mod session; mod upstream_adapter; @@ -16,15 +17,20 @@ use serde::{Serialize, de::DeserializeOwned}; pub use frame::{read_frame, validate_frame, write_frame}; pub use http::{HostHttpBody, HostHttpResponse}; pub use middleware::{ - MiddlewareBody, MiddlewareBodySender, MiddlewareCall, MiddlewareNext, MiddlewarePlugin, - MiddlewareRequest, MiddlewareResponse, + HttpBody, HttpCall, HttpFrame, HttpNext, HttpRequest, HttpResponse, MiddlewareBody, + MiddlewareBodySender, MiddlewareCall, MiddlewareInput, MiddlewareNext, MiddlewareOutput, + MiddlewarePlugin, MiddlewareRequest, MiddlewareResponse, MiddlewareResult, RequestCall, + ServiceCall, ServiceNext, ServiceResponse, TypedServiceCall, WebSocketCall, WebSocketDirection, + WebSocketKind, WebSocketMessage, WebSocketNext, WebSocketPayload, WebSocketSender, + WebSocketSession, }; pub use plugin::{ AuthorError, ComposedPlugin, Empty, Method, PluginBuilder, TypedCall, TypedReply, methods, }; pub use session::{ CallCancellation, CallFuture, CallReply, HostClient, HostReply, PluginCall, PluginHandler, - PluginSession, ResponseStream, SessionConfig, SessionError, StreamSender, + PluginSession, PullResponseFuture, PullResponseStream, ResponseStream, SessionConfig, + SessionError, StreamSender, }; pub use upstream_adapter::{UpstreamWebSocket, UpstreamWebSocketUpgrade}; diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs index 3ac64a382..0565185bb 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs @@ -107,16 +107,9 @@ pub const SCHEDULE_ACCOUNT: Method< decode_metadata_without_payload, encode_metadata, ); -pub const OBSERVE_REQUEST: Method = Method::new( - "policy.observe_request", - &[C::RequestLifecycle, C::Usage], - &[S::Observation], - decode_payload, - encode_metadata, -); -pub const OBSERVE_WEBSOCKET: Method = Method::new( - "websocket.response_event", - &[C::WebSocketObserver], +pub const OBSERVE: Method = Method::new( + "observer.observe", + &[C::Observer], &[S::Observation], decode_metadata, encode_metadata, diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/mod.rs index c9e4f3d41..b9498545b 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/mod.rs @@ -17,7 +17,7 @@ use crate::{ }, }; -use super::{CallFuture, CallReply, MiddlewareCall, MiddlewareResponse, PluginCall, PluginHandler}; +use super::{CallFuture, CallReply, MiddlewareInput, MiddlewareOutput, PluginCall, PluginHandler}; pub use typed::{Empty, Method, TypedCall, TypedReply}; @@ -110,16 +110,20 @@ impl PluginBuilder { /// # Errors /// /// 未声明中间件或重复注册时失败。 - pub fn middleware(mut self, handler: F) -> Result + pub fn middleware(mut self, handler: F) -> Result where - F: Fn(MiddlewareCall) -> Fut + Send + Sync + 'static, - Fut: Future> + Send + 'static, + C: MiddlewareInput, + F: Fn(C) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, { let declaration = self .manifest .contributes .get(&Capability::Middleware) .ok_or(AuthorError::Method(crate::call::middleware::HANDLE_METHOD))?; + if declaration.stages.iter().any(|stage| !C::accepts(*stage)) { + return Err(AuthorError::Method(crate::call::middleware::HANDLE_METHOD)); + } let stages = declaration.stages.clone(); let handler = Arc::new(handler); self.insert( @@ -131,7 +135,7 @@ impl PluginBuilder { if !allowed { return Err(typed::invalid_input()); } - handler(MiddlewareCall::try_from(call)?).await?.into_reply() + handler(C::decode(call)?).await?.encode() }) }), )?; diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs index de5e9cbfc..2185070ac 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs @@ -26,8 +26,7 @@ pub(super) fn validate( C::ModelCatalog => required(methods::MODEL_CATALOG_REGISTER.name)?, C::RetryPolicy => required(methods::RETRY_DECISION.name)?, C::Scheduler => required(methods::SCHEDULE_ACCOUNT.name)?, - C::RequestLifecycle | C::Usage => required(methods::OBSERVE_REQUEST.name)?, - C::WebSocketObserver => required(methods::OBSERVE_WEBSOCKET.name)?, + C::Observer => required(methods::OBSERVE.name)?, C::Management => { required(methods::MANAGEMENT_REGISTER.name)?; required(methods::MANAGEMENT_HANDLE.name)?; diff --git a/backend/crates/gateway-plugin/sdk/src/client/read.rs b/backend/crates/gateway-plugin/sdk/src/client/read.rs new file mode 100644 index 000000000..e1f49c59f --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/read.rs @@ -0,0 +1,31 @@ +//! 读取由资源持有,取消等待不会丢弃已发出的 RPC 或已收到的部分结果。 + +use std::{future::Future, pin::Pin}; + +pub(super) struct PendingRead(Option + Send>>>); + +impl Default for PendingRead { + fn default() -> Self { + Self(None) + } +} + +impl PendingRead { + pub(super) async fn run + Send + 'static>( + &mut self, + start: impl FnOnce() -> F, + ) -> T { + // 不后台预读;只有消费者再次等待时才继续驱动同一次读取。 + let result = self.0.get_or_insert_with(|| Box::pin(start())).await; + self.0 = None; + result + } + + pub(super) fn is_pending(&self) -> bool { + self.0.is_some() + } + + pub(super) fn clear(&mut self) { + self.0 = None; + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/resources.rs b/backend/crates/gateway-plugin/sdk/src/client/resources.rs index 06a9f6409..eba6b6f65 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/resources.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/resources.rs @@ -6,7 +6,7 @@ impl HostClient { /// 幂等创建或读取本实例分组,不覆盖管理员修改的现有属性。 /// /// # Errors - /// 权限、实例版本或输入不合法,或者宿主写入失败时返回错误。 + /// 实例版本或输入不合法,或者宿主写入失败时返回错误。 pub async fn ensure_group( &self, request: resources::GroupEnsureRequest, @@ -17,7 +17,7 @@ impl HostClient { /// 增量修改本实例分组的成员;保留账号在其他分组中的成员关系。 /// /// # Errors - /// 权限、实例版本、资源归属或输入不合法,或者宿主写入失败时返回错误。 + /// 实例版本、资源归属或输入不合法,或者宿主写入失败时返回错误。 pub async fn change_group_members( &self, request: resources::GroupMembersChange, @@ -28,7 +28,7 @@ impl HostClient { /// 幂等创建或读取仅绑定本实例分组的 Key;不返回密钥明文。 /// /// # Errors - /// 权限、实例版本、资源归属或输入不合法,或者宿主写入失败时返回错误。 + /// 实例版本、资源归属或输入不合法,或者宿主写入失败时返回错误。 pub async fn ensure_key( &self, request: resources::KeyEnsureRequest, diff --git a/backend/crates/gateway-plugin/sdk/src/client/session.rs b/backend/crates/gateway-plugin/sdk/src/client/session.rs index d5954d1bc..85e733aec 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/session.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/session.rs @@ -164,16 +164,23 @@ pub struct HostReply { #[derive(Clone)] pub struct HostClient { parent_id: u64, - deadline: Instant, + deadline: tokio::sync::watch::Receiver>, cancellation: CallCancellation, callbacks: Arc, output: Output, maximum_stream_chunk_bytes: usize, + credits: Arc, } impl HostClient { - pub(crate) const fn maximum_stream_chunk_bytes(&self) -> usize { - self.maximum_stream_chunk_bytes + pub(crate) fn maximum_stream_chunk_bytes(&self) -> usize { + let window = lock_unpoisoned(&self.credits.state).maximum_bytes; + if window == 0 { + self.maximum_stream_chunk_bytes + } else { + self.maximum_stream_chunk_bytes + .min(usize::try_from(window).unwrap_or(usize::MAX)) + } } /// 发起一次与父调用关联的宿主回调。 @@ -209,7 +216,7 @@ impl HostClient { let sent = tokio::select! { biased; () = self.cancellation.cancelled() => Err(SessionError::Cancelled), - () = tokio::time::sleep_until(self.deadline) => Err(SessionError::Timeout), + () = callback_deadline(self.deadline.clone()) => Err(SessionError::Timeout), result = self.output.send_data(frame) => result, }; if let Err(error) = sent { @@ -223,7 +230,7 @@ impl HostClient { self.callbacks.retire(id); Err(SessionError::Cancelled) } - () = tokio::time::sleep_until(self.deadline) => { + () = callback_deadline(self.deadline.clone()) => { self.callbacks.retire(id); Err(SessionError::Timeout) } @@ -292,10 +299,10 @@ enum StreamSource { Pull(Box), } -pub(crate) type PullResponseFuture<'a> = +pub type PullResponseFuture<'a> = Pin, PluginFault>>> + Send + 'a>>; -pub(crate) trait PullResponseStream: Send { +pub trait PullResponseStream: Send { fn next(&mut self) -> PullResponseFuture<'_>; } @@ -329,7 +336,8 @@ impl ResponseStream { ) } - pub(crate) fn pull(source: Box) -> Self { + /// 按消费进度拉取下一帧;返回 None 表示完成,丢弃流会丢弃生产者。 + pub fn pull(source: Box) -> Self { Self { source: StreamSource::Pull(source), declared_capacity: 1, @@ -355,7 +363,7 @@ impl ResponseStream { Ok(()) } - async fn next(&mut self) -> Option, PluginFault>> { + pub(crate) async fn next(&mut self) -> Option, PluginFault>> { match &mut self.source { StreamSource::Buffered(chunks) => chunks.pop_front().map(Ok), StreamSource::Channel(receiver) => receiver.recv().await, @@ -535,13 +543,13 @@ impl Output { async fn send_data_until( &self, frame: Frame, - deadline: Instant, + deadline: Option, cancellation: &CallCancellation, ) -> Result<(), SessionError> { tokio::select! { biased; () = cancellation.cancelled() => Err(SessionError::Cancelled), - () = tokio::time::sleep_until(deadline) => Err(SessionError::Timeout), + () = wait_deadline(deadline) => Err(SessionError::Timeout), result = self.send_data(frame) => result, } } @@ -951,15 +959,18 @@ impl SessionDriver { let deadline = Instant::now() .checked_add(Duration::from_millis(context.timeout_ms)) .ok_or(SessionError::Protocol)?; + let deadline = Some(deadline); + let (deadline_source, callback_deadline) = tokio::sync::watch::channel(deadline); let credits = Arc::new(CreditWindow::default()); let (cancellation, cancellation_signal) = CancellationSource::new(); let host = HostClient { parent_id: id, - deadline, + deadline: callback_deadline, cancellation: cancellation_signal.clone(), callbacks: Arc::clone(&self.callbacks), output: self.output.clone(), maximum_stream_chunk_bytes: self.config.maximum_stream_chunk_bytes, + credits: credits.clone(), }; let call = PluginCall { method, @@ -987,6 +998,7 @@ impl SessionDriver { output, lifecycle, deadline, + deadline_source, maximum_stream_chunk_bytes, maximum_chunks, ) @@ -1060,7 +1072,8 @@ async fn run_call( credits: Arc, output: Output, lifecycle: LifecycleHooks, - deadline: Instant, + deadline: Option, + deadline_source: tokio::sync::watch::Sender>, maximum_stream_chunk_bytes: usize, maximum_chunks: usize, ) -> Result<(), SessionError> { @@ -1069,7 +1082,7 @@ async fn run_call( let response = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), - () = tokio::time::sleep_until(deadline) => { + () = wait_deadline(deadline) => { lifecycle.cancel(context.clone()); output.send_control(fault_frame( id, @@ -1154,12 +1167,18 @@ async fn run_call( { return Ok(()); } + let deadline = if context.resource_stream { + None + } else { + deadline + }; + deadline_source.send_replace(deadline); let mut sequence = 0_u64; loop { let item = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), - () = tokio::time::sleep_until(deadline) => { + () = wait_deadline(deadline) => { lifecycle.cancel(context.clone()); output.send_control(end_frame( id, @@ -1231,7 +1250,7 @@ async fn send_initial( frame: Frame, context: &CallContext, lifecycle: &LifecycleHooks, - deadline: Instant, + deadline: Option, cancellation: &CallCancellation, ) -> Result { match output.send_data_until(frame, deadline, cancellation).await { @@ -1255,7 +1274,7 @@ async fn send_stream_data( id: u64, context: &CallContext, lifecycle: &LifecycleHooks, - deadline: Instant, + deadline: Option, cancellation: &CallCancellation, ) -> Result { match output.send_data_until(frame, deadline, cancellation).await { @@ -1313,7 +1332,7 @@ impl CreditWindow { async fn initial_window( &self, - deadline: Instant, + deadline: Option, cancellation: &CallCancellation, ) -> Result { loop { @@ -1327,7 +1346,7 @@ impl CreditWindow { tokio::select! { biased; () = cancellation.cancelled() => return Err(SessionError::Cancelled), - () = tokio::time::sleep_until(deadline) => return Err(SessionError::Timeout), + () = wait_deadline(deadline) => return Err(SessionError::Timeout), () = changed => {} } } @@ -1336,7 +1355,7 @@ impl CreditWindow { async fn take( &self, bytes: u64, - deadline: Instant, + deadline: Option, cancellation: &CallCancellation, ) -> Result<(), SessionError> { loop { @@ -1355,7 +1374,7 @@ impl CreditWindow { tokio::select! { biased; () = cancellation.cancelled() => return Err(SessionError::Cancelled), - () = tokio::time::sleep_until(deadline) => return Err(SessionError::Timeout), + () = wait_deadline(deadline) => return Err(SessionError::Timeout), () = changed => {} } } @@ -1432,3 +1451,22 @@ fn lock_unpoisoned(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } + +async fn wait_deadline(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending().await, + } +} + +async fn callback_deadline(mut deadline: tokio::sync::watch::Receiver>) { + loop { + let current = *deadline.borrow_and_update(); + tokio::select! { + () = wait_deadline(current) => return, + changed = deadline.changed() => { + if changed.is_err() { return wait_deadline(current).await; } + } + } + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs index be4713e10..18c8b73cb 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs @@ -1,8 +1,11 @@ //! 已选账号的受管 WebSocket;连接归属与跨轮续接由宿主管理。 +use tokio::sync::{Mutex, OnceCell}; + use super::{ - HostClient, + HostClient, HostReply, SessionError, http::{callback, empty_callback, invalid}, + read::PendingRead, }; use crate::{ ErrorCode, PluginFault, @@ -28,20 +31,21 @@ pub enum UpstreamWebSocketUpgrade { }, } -/// 本次调用的单消费者连接入口;不暴露凭据或可跨账号复用的连接句柄。 +/// 本次调用的连接入口;读写可同时进行,有先后依赖的发送须按顺序等待。 /// /// 结束调用时宿主关闭连接;只有成功终态携带连接内续接状态时才保留连接。 /// 因此丢弃此对象不会提前关闭宿主尚待确认的续接连接。 pub struct UpstreamWebSocket { host: HostClient, - closed: bool, + closed: OnceCell<()>, + read: Mutex>>, } impl HostClient { /// 建立连接,续接时复用宿主恢复的同一连接。 /// /// # Errors - /// 目标、权限、握手或续接归属无效时失败。 + /// 目标、握手或续接归属无效时失败。 pub async fn upstream_websocket( &self, request: UpstreamWebSocketRequest, @@ -56,7 +60,8 @@ impl HostClient { headers: response.headers, connection: UpstreamWebSocket { host: self.clone(), - closed: false, + closed: OnceCell::new(), + read: Mutex::new(PendingRead::default()), }, } } else { @@ -74,12 +79,8 @@ impl UpstreamWebSocket { /// /// # Errors /// 连接关闭、消息无效、网络失败或期限到达时失败。 - pub async fn send( - &mut self, - kind: WebSocketMessageKind, - body: Vec, - ) -> Result<(), PluginFault> { - if self.closed { + pub async fn send(&self, kind: WebSocketMessageKind, body: Vec) -> Result<(), PluginFault> { + if self.closed.get().is_some() { return Err(PluginFault::new( ErrorCode::InvalidInput, "WebSocket is closed", @@ -95,23 +96,38 @@ impl UpstreamWebSocket { } /// 按需读取完整消息,EOF 后再次读取返回 None;Ping/Pong 由宿主处理。 + /// 取消等待不丢弃消息;同时只允许一个读取者,发送和关闭不受读取等待阻塞。 /// /// # Errors /// 父调用结束、网络失败或期限到达时失败。 - pub async fn read(&mut self) -> Result)>, PluginFault> { - if self.closed { + pub async fn read(&self) -> Result)>, PluginFault> { + if self.closed.get().is_some() { return Ok(None); } - let (read, body): (UpstreamWebSocketRead, _) = callback( - &self.host, - "host.upstream.websocket.read", - serde_json::json!({}), - vec![], - ) - .await?; + let mut pending = self + .read + .try_lock() + .map_err(|_| PluginFault::new(ErrorCode::Capacity, "WebSocket already has a reader"))?; + let reply = pending + .run(|| { + let host = self.host.clone(); + async move { + host.call( + "host.upstream.websocket.read", + serde_json::json!({}), + vec![], + ) + .await + } + }) + .await + .map_err(SessionError::into_plugin_fault)?; + let read: UpstreamWebSocketRead = + serde_json::from_value(reply.result).map_err(|_| invalid())?; + let body = reply.payload; match (read.eof, read.kind) { (true, None) if body.is_empty() => { - self.closed = true; + let _ = self.closed.set(()); Ok(None) } (false, Some(kind)) => Ok(Some((kind, body))), @@ -119,22 +135,26 @@ impl UpstreamWebSocket { } } - /// 提前关闭并放弃连接内续接,重复关闭不调用宿主。 + /// 提前关闭并放弃连接内续接;并发关闭等待同一次回调,失败或取消后可重试。 /// /// # Errors /// 父调用已结束或宿主拒绝释放时失败。 - pub async fn close(&mut self) -> Result<(), PluginFault> { - if self.closed { - return Ok(()); + pub async fn close(&self) -> Result<(), PluginFault> { + self.closed + .get_or_try_init(|| async { + empty_callback( + &self.host, + "host.upstream.websocket.close", + serde_json::json!({}), + vec![], + ) + .await + }) + .await?; + // 活跃读取由宿主关闭唤醒;不等待读锁,避免 close 与 read 互相等待。 + if let Ok(mut pending) = self.read.try_lock() { + pending.clear(); } - empty_callback( - &self.host, - "host.upstream.websocket.close", - serde_json::json!({}), - vec![], - ) - .await?; - self.closed = true; Ok(()) } } diff --git a/backend/crates/gateway-plugin/sdk/src/context.rs b/backend/crates/gateway-plugin/sdk/src/context.rs index f55e8c14a..10728f90f 100644 --- a/backend/crates/gateway-plugin/sdk/src/context.rs +++ b/backend/crates/gateway-plugin/sdk/src/context.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{Contributions, Permission, Stage}; +use crate::{Contributions, Stage}; /// 一次会话绑定不可变制品与配置;重启必须使用新的 incarnation。 #[derive(Clone, PartialEq, Serialize, Deserialize)] @@ -13,7 +13,6 @@ pub struct Handshake { pub generation: u64, pub incarnation: String, pub configuration: serde_json::Value, - pub permissions: Vec, #[serde(deserialize_with = "crate::capability::deserialize_contributions")] pub contributes: Contributions, } @@ -41,6 +40,8 @@ pub struct CallContext { pub incarnation: String, pub stage: Stage, pub timeout_ms: u64, + /// 首个流式结果返回后由资源 owner 控制取消,不再沿用初始调用期限。 + pub resource_stream: bool, pub resource_scope_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub request_id: Option, diff --git a/backend/crates/gateway-plugin/sdk/src/error.rs b/backend/crates/gateway-plugin/sdk/src/error.rs index 61c9759ba..0d57a1b10 100644 --- a/backend/crates/gateway-plugin/sdk/src/error.rs +++ b/backend/crates/gateway-plugin/sdk/src/error.rs @@ -33,6 +33,9 @@ pub struct PluginFault { pub send_state: SendState, #[serde(default, skip_serializing_if = "Option::is_none")] pub http_status: Option, + /// 原始边界错误详情;按合同完整传递,不作为诊断日志输出。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, } impl std::fmt::Debug for PluginFault { @@ -54,6 +57,7 @@ impl PluginFault { message: message.into(), send_state: SendState::NotSent, http_status: None, + details: None, } } } diff --git a/backend/crates/gateway-plugin/sdk/src/lib.rs b/backend/crates/gateway-plugin/sdk/src/lib.rs index b08afd6f5..81ff0900a 100644 --- a/backend/crates/gateway-plugin/sdk/src/lib.rs +++ b/backend/crates/gateway-plugin/sdk/src/lib.rs @@ -9,9 +9,7 @@ mod error; mod manifest; mod message; -pub use capability::{ - Capability, ContributionDeclaration, Contributions, FailurePolicy, Permission, Stage, -}; +pub use capability::{Capability, ContributionDeclaration, Contributions, FailurePolicy, Stage}; pub use context::{CallContext, Handshake}; pub use error::{ErrorCode, PluginFault, SendState}; pub use manifest::{ diff --git a/backend/crates/gateway-plugin/sdk/src/manifest.rs b/backend/crates/gateway-plugin/sdk/src/manifest.rs index a357b092d..5dbaae476 100644 --- a/backend/crates/gateway-plugin/sdk/src/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/src/manifest.rs @@ -10,10 +10,10 @@ use serde::{ }; use serde_json::Value; -use crate::{Capability, Contributions, PROTOCOL_VERSION, Permission, Stage}; +use crate::{Capability, Contributions, PROTOCOL_VERSION, Stage}; /// 当前插件清单格式版本。 -pub const MANIFEST_VERSION: u32 = 1; +pub const MANIFEST_VERSION: u32 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -109,8 +109,6 @@ pub struct Manifest { pub runtime: RuntimeKind, #[serde(deserialize_with = "crate::capability::deserialize_contributions")] pub contributes: Contributions, - #[serde(default)] - pub permissions: BTreeSet, #[serde(default = "empty_configuration_schema")] pub configuration_schema: serde_json::Value, #[serde(default)] @@ -271,7 +269,6 @@ impl Manifest { .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) || !contribution_ids.insert(&declaration.id) - || declaration.stages.len() > 32 || stages.len() != declaration.stages.len() || input_formats.len() != declaration.input_formats.len() || output_formats.len() != declaration.output_formats.len() @@ -282,10 +279,16 @@ impl Manifest { .any(|format| !platform_component(format)) || (*capability == Capability::Middleware && (declaration.stages.is_empty() - || declaration - .stages - .iter() - .any(|stage| !matches!(stage, Stage::Request | Stage::Attempt)))) + || declaration.stages.iter().any(|stage| { + !matches!( + stage, + Stage::Http + | Stage::WebSocket + | Stage::Service + | Stage::Request + | Stage::Attempt + ) + }))) || (*capability != Capability::Middleware && declaration.stages != capability.fixed_stages()) { diff --git a/backend/crates/gateway-plugin/sdk/src/message.rs b/backend/crates/gateway-plugin/sdk/src/message.rs index b070cd77e..c9bfe46d3 100644 --- a/backend/crates/gateway-plugin/sdk/src/message.rs +++ b/backend/crates/gateway-plugin/sdk/src/message.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::{CallContext, Handshake, PluginFault}; -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// 元数据与二进制载荷分开,流分块不经过 JSON/base64。 #[derive(Clone, PartialEq, Serialize, Deserialize)] diff --git a/backend/crates/gateway-plugin/sdk/tests/call/host.rs b/backend/crates/gateway-plugin/sdk/tests/call/host.rs index fac63383d..52b8f2b6a 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/host.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/host.rs @@ -176,6 +176,7 @@ fn model_and_affinity_callbacks_keep_targets_explicit_and_closed() { fn model_event_batch_preserves_binary_wire_and_rejects_trailing_data() { let batch = ModelEventBatch { events: vec![ExecutionEvent { + host: None, facts: vec![CanonicalEvent::TextDelta { index: 0, text: "hello".to_owned(), @@ -201,3 +202,38 @@ fn model_event_batch_preserves_binary_wire_and_rejects_trailing_data() { trailing.push(0); assert!(ModelEventBatch::decode(&trailing).is_err()); } + +#[test] +fn execution_facts_preserve_both_binary_sources_and_do_not_become_output_authority() { + use gateway_plugin_sdk::call::{middleware::MiddlewareBodyFrame, model::facts::ExecutionFacts}; + let wire = |body| WireEvent { + protocol: "openai".into(), + payload: WirePayload::RawBody { body }, + }; + let event = ExecutionEvent { + facts: vec![], + wire: Some(wire(vec![0, 255, 1])), + host: Some(Box::new(ExecutionFacts { + middleware_origin_wire: Some(wire(vec![128, 0, 3])), + middleware_transformed: true, + ..Default::default() + })), + }; + let encoded = event.clone().encode().unwrap(); + let decoded = ExecutionEvent::decode(&encoded).unwrap(); + assert!(decoded == event); + let mut output = MiddlewareBodyFrame::new(b"changed".to_vec(), false); + output.facts = Some(Box::new(decoded)); + let output = MiddlewareBodyFrame::decode(&output.encode()).unwrap(); + assert_eq!(output.payload, b"changed"); + assert!( + output.facts.is_none(), + "snapshots are observations, not a second billing source" + ); + let mut truncated = encoded.clone(); + truncated.pop(); + assert!(ExecutionEvent::decode(&truncated).is_err()); + let mut wrong_version = encoded; + wrong_version[3] = b'1'; + assert!(ExecutionEvent::decode(&wrong_version).is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs b/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs similarity index 95% rename from backend/crates/gateway-plugin/sdk/tests/call/middleware.rs rename to backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs index c1bf4c67b..aab255125 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs @@ -30,6 +30,10 @@ fn middleware_capability_and_mounts_have_one_stable_wire_vocabulary() { #[test] fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() { let request = MiddlewareRequestHead { + settings_sources: serde_json::Value::Null, + settings: serde_json::Value::Null, + client_key_id: "fixture-key".into(), + account_group_ids: vec!["fixture-group".into()], request_id: "request-1".into(), mount: MiddlewareMount::Attempt, attempt_index: Some(2), @@ -44,7 +48,6 @@ fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() { name: "x-feature".into(), value: b"enabled".to_vec(), }], - body_visible: true, }; let wire = serde_json::to_value(&request).unwrap(); assert_eq!(wire["mount"], "attempt"); @@ -64,6 +67,7 @@ fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() { #[test] fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() { let preserve = MiddlewareNextRequest { + settings: None, protocol: None, header_mutations: Vec::new(), body: MiddlewareRequestBody::Preserve, @@ -75,6 +79,7 @@ fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() ); let replace = MiddlewareNextRequest { + settings: None, protocol: Some("xai".into()), header_mutations: vec![ MiddlewareHeaderMutation::Remove { @@ -108,6 +113,7 @@ fn response_body_modes_distinguish_transfer_empty_and_plugin_stream() { framing: MiddlewareBodyFraming::SseEvent, }; let next = MiddlewareNextResponse { + metadata: None, response: "opaque-response".into(), protocol: "openai".into(), status: 200, diff --git a/backend/crates/gateway-plugin/sdk/tests/call/observation.rs b/backend/crates/gateway-plugin/sdk/tests/call/observation.rs index c6e2f46d4..aedaaec84 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/observation.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/observation.rs @@ -1,9 +1,16 @@ -use gateway_plugin_sdk::call::observation::ObserveWebSocketResponse; +use gateway_plugin_sdk::{ + SendState, + call::observation::{ + Event, RequestCompleted, RequestCost, RequestCostSource, RequestCostStatus, RequestFailure, + RequestMoney, RequestOutcome, RequestTerminal, RequestTimings, RequestUsage, + WebSocketResponse, + }, +}; use serde_json::json; #[test] fn websocket_response_observation_keeps_payload_out_of_json_and_rejects_unknown_fields() { - let event = ObserveWebSocketResponse { + let event = WebSocketResponse { event_id: "req_observed:websocket:2:7".into(), request_id: "req_observed".into(), config_revision: 9, @@ -24,12 +31,12 @@ fn websocket_response_observation_keeps_payload_out_of_json_and_rejects_unknown_ assert!(encoded.get("headers").is_none()); assert!(encoded.get("body").is_none()); assert_eq!( - serde_json::from_value::(encoded).unwrap(), + serde_json::from_value::(encoded).unwrap(), event ); assert!( - serde_json::from_value::(json!({ + serde_json::from_value::(json!({ "event_id":"req_observed:websocket:2:7", "request_id":"req_observed", "config_revision":9, @@ -44,3 +51,194 @@ fn websocket_response_observation_keeps_payload_out_of_json_and_rejects_unknown_ .is_err() ); } + +fn observation() -> RequestCompleted { + RequestCompleted { + event_id: "request-42:terminal".into(), + request_id: "request-42".into(), + config_revision: 7, + operation: "generate".into(), + requested_model: Some("public-model".into()), + client_key_id: Some("key_fixture".into()), + account_id: Some("acct_fixture".into()), + upstream_model: Some("upstream-model".into()), + response_model: Some("reported-model".into()), + service_tier: Some("default".into()), + provider: None, + completed_at_ms: 1234, + terminal: RequestTerminal { + outcome: RequestOutcome::Succeeded, + send_state: SendState::Sent, + attempt_count: 1, + client_status_code: Some(200), + error_code: None, + }, + usage: RequestUsage::default(), + } +} + +#[test] +fn terminal_outcomes_have_stable_wire_names() { + for (outcome, name) in [ + (RequestOutcome::Succeeded, "succeeded"), + (RequestOutcome::Failed, "failed"), + (RequestOutcome::Rejected, "rejected"), + (RequestOutcome::Cancelled, "cancelled"), + (RequestOutcome::Incomplete, "incomplete"), + ] { + let terminal = RequestTerminal { + outcome, + send_state: SendState::NotSent, + attempt_count: 0, + client_status_code: None, + error_code: None, + }; + let wire = serde_json::to_value(&terminal).unwrap(); + assert_eq!( + wire, + json!({"outcome": name, "send_state": "not_sent", "attempt_count": 0}) + ); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + terminal + ); + } +} + +#[test] +fn observation_exposes_selected_and_reported_facts_without_inventing_values() { + let mut request = observation(); + let wire = serde_json::to_value(&request).unwrap(); + assert_eq!(wire["client_key_id"], "key_fixture"); + assert_eq!(wire["account_id"], "acct_fixture"); + assert_eq!(wire["upstream_model"], "upstream-model"); + assert_eq!(wire["response_model"], "reported-model"); + assert_eq!(wire["service_tier"], "default"); + request.account_id = None; + request.upstream_model = None; + request.response_model = None; + request.service_tier = None; + let wire = serde_json::to_value(request).unwrap(); + for field in [ + "account_id", + "upstream_model", + "response_model", + "service_tier", + ] { + assert!(wire.get(field).is_none(), "unknown {field} must be omitted"); + } +} + +#[test] +fn completed_event_requires_terminal_and_usage_without_inventing_unknown_values() { + let request = observation(); + let event = Event::RequestCompleted(Box::new(request.clone())); + let encoded = serde_json::to_value(&event).unwrap(); + assert_eq!(encoded["event"], "request_completed"); + assert_eq!(encoded["data"]["terminal"]["outcome"], "succeeded"); + assert_eq!(encoded["data"]["usage"], json!({})); + assert_eq!(serde_json::from_value::(encoded).unwrap(), event); + for field in ["terminal", "usage"] { + let mut incomplete = serde_json::to_value(&request).unwrap(); + incomplete.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(incomplete).is_err()); + } +} + +#[test] +fn missing_usage_is_not_invented_as_zero_or_derived_totals() { + let usage: RequestUsage = serde_json::from_value(json!({"input_tokens": 0})).unwrap(); + assert_eq!(usage.input_tokens, Some(0)); + assert_eq!(usage.output_tokens, None); + assert_eq!(usage.total_tokens, None); + assert_eq!( + serde_json::to_value(usage).unwrap(), + json!({"input_tokens": 0}) + ); + + assert!(serde_json::from_value::(json!({"output_tokens": -1})).is_err()); +} + +#[test] +fn observation_contract_rejects_unrecognized_fields_and_outcomes() { + let mut request = serde_json::to_value(observation()).unwrap(); + request["request_body"] = json!({}); + assert!(serde_json::from_value::(request).is_err()); + assert!(serde_json::from_value::(json!({"custom_tokens": 1})).is_err()); + assert!( + serde_json::from_value::(json!({ + "outcome": "succeeded", "send_state": "sent", "attempt_count": 1, + "internal_error": "not a public observation field" + })) + .is_err() + ); + assert!(serde_json::from_value::(json!("running")).is_err()); +} + +#[test] +fn cost_timings_and_failure_keep_unknown_facts_explicit_and_safe() { + let unknown = RequestCost { + status: RequestCostStatus::Unknown, + source: RequestCostSource::Unavailable, + total: None, + }; + assert_eq!( + serde_json::to_value(&unknown).unwrap(), + json!({"status":"unknown","source":"unavailable"}) + ); + let known = RequestCost { + status: RequestCostStatus::Known, + source: RequestCostSource::ProviderReported, + total: Some(RequestMoney { + amount: "0.0123".into(), + currency: "USD".into(), + }), + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&known).unwrap()).unwrap(), + known + ); + + let timings = RequestTimings { + first_text_ms: Some(12), + latency_ms: Some(34), + ..RequestTimings::default() + }; + assert_eq!( + serde_json::to_value(&timings).unwrap(), + json!({"first_text_ms":12,"latency_ms":34}) + ); + + let failure = RequestFailure { + outcome: RequestOutcome::Failed, + send_state: SendState::Sent, + attempt_count: 2, + client_status_code: Some(502), + upstream_status_code: Some(503), + error_code: Some("upstream_unavailable".into()), + retry_after_ms: Some(1_000), + }; + let wire = serde_json::to_value(&failure).unwrap(); + assert!(wire.get("message").is_none()); + assert!(wire.get("raw_upstream_error").is_none()); + let mut unsafe_wire = wire.clone(); + unsafe_wire["provider_error_code"] = json!("opaque-upstream-value"); + assert!(serde_json::from_value::(unsafe_wire).is_err()); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + failure + ); + + let usage = RequestUsage { + cost: Some(known), + timings: Some(timings), + failure: Some(failure), + ..RequestUsage::default() + }; + let wire = serde_json::to_value(&usage).unwrap(); + assert!(wire.get("input_tokens").is_none()); + assert_eq!(wire["cost"]["total"]["amount"], "0.0123"); + assert_eq!(wire["timings"]["latency_ms"], 34); + assert!(wire["failure"].get("provider_error_code").is_none()); + assert_eq!(serde_json::from_value::(wire).unwrap(), usage); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs index e67c0f812..fd2af7989 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs @@ -1,229 +1,9 @@ -use gateway_plugin_sdk::{ - SendState, - call::policy::{ - AccountScheduleCandidate, AccountScheduleDecision, AccountScheduleRequest, - ModelRouteDecision, ModelRouteRequest, ObserveRequest, PolicyHeader, RequestCost, - RequestCostSource, RequestCostStatus, RequestFailure, RequestMoney, RequestOutcome, - RequestTerminal, RequestTimings, RequestUsage, - }, +use gateway_plugin_sdk::call::policy::{ + AccountScheduleCandidate, AccountScheduleDecision, AccountScheduleRequest, ModelRouteDecision, + ModelRouteRequest, PolicyHeader, }; use serde_json::json; -fn observation() -> ObserveRequest { - ObserveRequest { - event_id: "request-42:terminal".into(), - request_id: "request-42".into(), - config_revision: 7, - operation: "generate".into(), - requested_model: Some("public-model".into()), - client_key_id: Some("key_fixture".into()), - account_id: Some("acct_fixture".into()), - upstream_model: Some("upstream-model".into()), - response_model: Some("reported-model".into()), - service_tier: Some("default".into()), - provider: None, - completed_at_ms: 1234, - terminal: None, - usage: None, - } -} - -#[test] -fn terminal_outcomes_have_stable_wire_names() { - for (outcome, name) in [ - (RequestOutcome::Succeeded, "succeeded"), - (RequestOutcome::Failed, "failed"), - (RequestOutcome::Rejected, "rejected"), - (RequestOutcome::Cancelled, "cancelled"), - (RequestOutcome::Incomplete, "incomplete"), - ] { - let terminal = RequestTerminal { - outcome, - send_state: SendState::NotSent, - attempt_count: 0, - client_status_code: None, - error_code: None, - }; - let wire = serde_json::to_value(&terminal).unwrap(); - assert_eq!( - wire, - json!({"outcome": name, "send_state": "not_sent", "attempt_count": 0}) - ); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - terminal - ); - } -} - -#[test] -fn observation_exposes_selected_and_reported_facts_without_inventing_values() { - let mut request = observation(); - let wire = serde_json::to_value(&request).unwrap(); - assert_eq!(wire["client_key_id"], "key_fixture"); - assert_eq!(wire["account_id"], "acct_fixture"); - assert_eq!(wire["upstream_model"], "upstream-model"); - assert_eq!(wire["response_model"], "reported-model"); - assert_eq!(wire["service_tier"], "default"); - request.account_id = None; - request.upstream_model = None; - request.response_model = None; - request.service_tier = None; - let wire = serde_json::to_value(request).unwrap(); - for field in [ - "account_id", - "upstream_model", - "response_model", - "service_tier", - ] { - assert!(wire.get(field).is_none(), "unknown {field} must be omitted"); - } -} - -#[test] -fn observation_sections_are_independent_and_omitted_when_not_subscribed() { - let mut request = observation(); - request.usage = Some(RequestUsage { - input_tokens: Some(0), - output_tokens: Some(12), - ..RequestUsage::default() - }); - let usage_only = serde_json::to_value(&request).unwrap(); - assert!(usage_only.get("terminal").is_none()); - assert!(usage_only.get("provider").is_none()); - assert_eq!( - usage_only["usage"], - json!({"input_tokens": 0, "output_tokens": 12}) - ); - assert_eq!( - serde_json::from_value::(usage_only).unwrap(), - request - ); - - request.usage = None; - request.terminal = Some(RequestTerminal { - outcome: RequestOutcome::Rejected, - send_state: SendState::NotSent, - attempt_count: 0, - client_status_code: None, - error_code: Some("invalid_request".into()), - }); - let lifecycle_only = serde_json::to_value(&request).unwrap(); - assert!(lifecycle_only.get("usage").is_none()); - assert_eq!( - serde_json::from_value::(lifecycle_only).unwrap(), - request - ); - - request.provider = Some("example-provider".into()); - request.usage = Some(RequestUsage::default()); - let combined = serde_json::to_value(&request).unwrap(); - assert!(combined.get("terminal").is_some()); - assert_eq!(combined["usage"], json!({})); - assert_eq!( - serde_json::from_value::(combined).unwrap(), - request - ); -} - -#[test] -fn missing_usage_is_not_invented_as_zero_or_derived_totals() { - let usage: RequestUsage = serde_json::from_value(json!({"input_tokens": 0})).unwrap(); - assert_eq!(usage.input_tokens, Some(0)); - assert_eq!(usage.output_tokens, None); - assert_eq!(usage.total_tokens, None); - assert_eq!( - serde_json::to_value(usage).unwrap(), - json!({"input_tokens": 0}) - ); - - assert!(serde_json::from_value::(json!({"output_tokens": -1})).is_err()); -} - -#[test] -fn observation_contract_rejects_unrecognized_fields_and_outcomes() { - let mut request = serde_json::to_value(observation()).unwrap(); - request["request_body"] = json!({}); - assert!(serde_json::from_value::(request).is_err()); - assert!(serde_json::from_value::(json!({"custom_tokens": 1})).is_err()); - assert!( - serde_json::from_value::(json!({ - "outcome": "succeeded", "send_state": "sent", "attempt_count": 1, - "internal_error": "not a public observation field" - })) - .is_err() - ); - assert!(serde_json::from_value::(json!("running")).is_err()); -} - -#[test] -fn cost_timings_and_failure_keep_unknown_facts_explicit_and_safe() { - let unknown = RequestCost { - status: RequestCostStatus::Unknown, - source: RequestCostSource::Unavailable, - total: None, - }; - assert_eq!( - serde_json::to_value(&unknown).unwrap(), - json!({"status":"unknown","source":"unavailable"}) - ); - let known = RequestCost { - status: RequestCostStatus::Known, - source: RequestCostSource::ProviderReported, - total: Some(RequestMoney { - amount: "0.0123".into(), - currency: "USD".into(), - }), - }; - assert_eq!( - serde_json::from_value::(serde_json::to_value(&known).unwrap()).unwrap(), - known - ); - - let timings = RequestTimings { - first_text_ms: Some(12), - latency_ms: Some(34), - ..RequestTimings::default() - }; - assert_eq!( - serde_json::to_value(&timings).unwrap(), - json!({"first_text_ms":12,"latency_ms":34}) - ); - - let failure = RequestFailure { - outcome: RequestOutcome::Failed, - send_state: SendState::Sent, - attempt_count: 2, - client_status_code: Some(502), - upstream_status_code: Some(503), - error_code: Some("upstream_unavailable".into()), - retry_after_ms: Some(1_000), - }; - let wire = serde_json::to_value(&failure).unwrap(); - assert!(wire.get("message").is_none()); - assert!(wire.get("raw_upstream_error").is_none()); - let mut unsafe_wire = wire.clone(); - unsafe_wire["provider_error_code"] = json!("opaque-upstream-value"); - assert!(serde_json::from_value::(unsafe_wire).is_err()); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - failure - ); - - let usage = RequestUsage { - cost: Some(known), - timings: Some(timings), - failure: Some(failure), - ..RequestUsage::default() - }; - let wire = serde_json::to_value(&usage).unwrap(); - assert!(wire.get("input_tokens").is_none()); - assert_eq!(wire["cost"]["total"]["amount"], "0.0123"); - assert_eq!(wire["timings"]["latency_ms"], 34); - assert!(wire["failure"].get("provider_error_code").is_none()); - assert_eq!(serde_json::from_value::(wire).unwrap(), usage); -} - #[test] fn route_and_scheduler_decisions_are_explicit_and_strict() { let route = ModelRouteRequest { diff --git a/backend/crates/gateway-plugin/sdk/tests/client/http.rs b/backend/crates/gateway-plugin/sdk/tests/client/http.rs index d809b496f..0631a6ae4 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/http.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/http.rs @@ -15,7 +15,7 @@ struct NetworkHandler; impl PluginHandler for NetworkHandler { fn call(&self, call: PluginCall) -> CallFuture<'_> { Box::pin(async move { - if call.method == "websocket" { + if call.method.starts_with("websocket") { let upgrade = call .host .upstream_websocket(UpstreamWebSocketRequest { @@ -24,12 +24,49 @@ impl PluginHandler for NetworkHandler { headers: vec![], }) .await?; - let UpstreamWebSocketUpgrade::Connected { mut connection, .. } = upgrade else { + let UpstreamWebSocketUpgrade::Connected { connection, .. } = upgrade else { let UpstreamWebSocketUpgrade::Rejected { status, body, .. } = upgrade else { unreachable!() }; return Ok(CallReply::unary(json!({"status":status}), body)); }; + if call.method == "websocket_cancel_close" { + { + let close = connection.close(); + tokio::pin!(close); + let first = std::future::poll_fn(|context| { + std::task::Poll::Ready(close.as_mut().poll(context)) + }) + .await; + assert!(first.is_pending()); + } + connection.close().await?; + return Ok(CallReply::unary(json!({}), vec![])); + } + if call.method == "websocket_close_during_read" { + let read = connection.read(); + tokio::pin!(read); + let first = std::future::poll_fn(|context| { + std::task::Poll::Ready(read.as_mut().poll(context)) + }) + .await; + assert!(first.is_pending()); + assert_eq!( + connection.read().await.unwrap_err().code, + ErrorCode::Capacity + ); + connection.close().await?; + assert!(read.await?.is_none()); + return Ok(CallReply::unary(json!({}), vec![])); + } + if call.method == "websocket_duplex" { + let (message, ()) = tokio::try_join!( + connection.read(), + connection.send(WebSocketMessageKind::Text, b"control".to_vec()) + )?; + tokio::try_join!(connection.close(), connection.close())?; + return Ok(CallReply::unary(json!({}), message.unwrap().1)); + } connection .send(WebSocketMessageKind::Text, b"request".to_vec()) .await?; @@ -221,3 +258,135 @@ async fn websocket_session_handles_messages_eof_and_http_rejection() { assert_eq!(response.payload, b"unauthorized"); shutdown(&mut host, task).await; } + +#[tokio::test] +async fn websocket_send_and_read_callbacks_can_be_in_flight_together() { + let (mut host, task) = start_session(NetworkHandler).await; + send_call(&mut host, 1, "websocket_duplex", json!({}), vec![]).await; + reply_callback( + &mut host, + "host.upstream.websocket.open", + json!({"status":101,"headers":[],"stream":null}), + vec![], + ) + .await; + let mut read_id = None; + for _ in 0..2 { + let frame = receive(&mut host).await; + let Message::Callback { id, method, .. } = frame.message else { + panic!("expected callback"); + }; + if method == "host.upstream.websocket.read" { + read_id = Some(id); + } else { + assert_eq!(method, "host.upstream.websocket.send"); + assert_eq!(frame.payload, b"control"); + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id, + result: json!({}), + }, + payload: vec![], + }, + ) + .await + .unwrap(); + } + } + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: read_id.unwrap(), + result: json!({"eof":false,"kind":"text"}), + }, + payload: b"acknowledged".to_vec(), + }, + ) + .await + .unwrap(); + reply_callback( + &mut host, + "host.upstream.websocket.close", + json!({}), + vec![], + ) + .await; + let response = receive(&mut host).await; + assert!(matches!(response.message, Message::Result { id: 1, .. })); + assert_eq!(response.payload, b"acknowledged"); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn websocket_close_can_be_retried_after_cancelling_its_future() { + let (mut host, task) = start_session(NetworkHandler).await; + send_call(&mut host, 1, "websocket_cancel_close", json!({}), vec![]).await; + reply_callback( + &mut host, + "host.upstream.websocket.open", + json!({"status":101,"headers":[],"stream":null}), + vec![], + ) + .await; + // 第一条关闭已发送,但等待其结果的 future 已取消;迟到回包不应中断重试。 + for _ in 0..2 { + reply_callback( + &mut host, + "host.upstream.websocket.close", + json!({}), + vec![], + ) + .await; + } + let response = receive(&mut host).await; + assert!(matches!(response.message, Message::Result { id: 1, .. })); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn websocket_close_does_not_wait_for_a_pending_reader() { + let (mut host, task) = start_session(NetworkHandler).await; + send_call( + &mut host, + 1, + "websocket_close_during_read", + json!({}), + vec![], + ) + .await; + reply_callback( + &mut host, + "host.upstream.websocket.open", + json!({"status":101,"headers":[],"stream":null}), + vec![], + ) + .await; + let Message::Callback { id, method, .. } = receive(&mut host).await.message else { + panic!("应发出读取回调") + }; + assert_eq!(method, "host.upstream.websocket.read"); + reply_callback( + &mut host, + "host.upstream.websocket.close", + json!({}), + vec![], + ) + .await; + write_frame( + &mut host.writer, + &Frame::control(Message::Result { + id, + result: json!({"eof":true,"kind":null}), + }), + ) + .await + .unwrap(); + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + shutdown(&mut host, task).await; +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs index ab7877ff3..52cd2dfea 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs @@ -1,3 +1,4 @@ mod http; mod plugin; +mod read; mod session; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs index 600df7f14..2b0815313 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs @@ -32,7 +32,7 @@ const MAXIMUM_STREAM_CHUNK_BYTES: usize = 64 * 1024; fn author_manifest(contributes: Value, state: Value) -> Vec { serde_json::to_vec(&json!({ - "manifestVersion": 1, + "manifestVersion":2, "name": "composed", "displayName": "Composed", "publisher": "9acme", @@ -92,6 +92,7 @@ async fn composed_plugin_registers_and_dispatches_multiple_typed_entries() { async move { management_calls.fetch_add(1, Ordering::Relaxed); Ok(TypedReply::new(ManagementResponse { + headers: Vec::new(), status: 201, content_type: call .request @@ -150,6 +151,7 @@ async fn composed_plugin_registers_and_dispatches_multiple_typed_entries() { "management.handle", Stage::Management, serde_json::to_value(ManagementRequest { + headers: Vec::new(), method: "POST".into(), path: "echo".into(), query: String::new(), @@ -450,7 +452,7 @@ async fn start_session( generation: 1, incarnation: "test-incarnation".into(), configuration: json!({}), - permissions: Vec::new(), + contributes, }, }), @@ -492,6 +494,7 @@ async fn send_call( incarnation: "test-incarnation".into(), stage, timeout_ms: 1_000, + resource_stream: false, resource_scope_id: format!("scope-{id}"), request_id: None, attempt_id: None, diff --git a/backend/crates/gateway-plugin/sdk/tests/client/read.rs b/backend/crates/gateway-plugin/sdk/tests/client/read.rs new file mode 100644 index 000000000..16e12d321 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/client/read.rs @@ -0,0 +1,423 @@ +use super::session; +use gateway_plugin_sdk::{ + ErrorCode, Frame, Message, Stage, + call::{ + host::HttpRequest, + middleware::{BODY_FACTS_METHOD, BODY_READ_METHOD, HANDLE_METHOD, NEXT_METHOD}, + upstream_adapter::UpstreamWebSocketRequest, + }, + client::{ + CallFuture, CallReply, HttpCall, HttpFrame, HttpResponse, MiddlewareInput, + MiddlewareOutput, PluginCall, PluginHandler, RequestCall, UpstreamWebSocketUpgrade, + WebSocketCall, write_frame, + }, +}; +use serde_json::{Value, json}; +use std::{future::Future, sync::Arc, time::Duration}; +use tokio::sync::Notify; + +#[derive(Default)] +struct Signals { + cancel: Notify, + dropped: Notify, + resume: Notify, +} +struct Reader(Arc); +impl PluginHandler for Reader { + fn call(&self, call: PluginCall) -> CallFuture<'_> { + Box::pin(async move { + let bytes = if call.method == "http" { + let mut response = call + .host + .http( + HttpRequest { + method: "GET".into(), + url: "https://example.test/stream".into(), + headers: vec![], + }, + vec![], + ) + .await?; + self.0.interrupt(response.body.read()).await; + response.body.read().await?.unwrap() + } else { + let UpstreamWebSocketUpgrade::Connected { connection, .. } = call + .host + .upstream_websocket(UpstreamWebSocketRequest { + path: "responses".into(), + query: vec![], + headers: vec![], + }) + .await? + else { + panic!("握手失败") + }; + self.0.interrupt(connection.read()).await; + connection.read().await?.unwrap().1 + }; + Ok(CallReply::unary(json!({}), bytes)) + }) + } +} +async fn callback_id(host: &mut session::HostPeer, expected: &str) -> u64 { + let frame = session::receive(host).await; + let Message::Callback { id, method, .. } = frame.message else { + panic!("没有收到回调:{:?}", frame.message) + }; + assert_eq!(method, expected); + id +} +async fn reply(host: &mut session::HostPeer, id: u64, result: Value, bytes: &[u8]) { + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { id, result }, + payload: bytes.into(), + }, + ) + .await + .unwrap(); +} +async fn verify_cancelled_read(method: &str) { + let signals = Arc::new(Signals::default()); + let (mut host, task) = session::start_session(Reader(signals.clone())).await; + session::send_call(&mut host, 1, method, json!({}), vec![]).await; + let (open, read, status, stream, data) = if method == "http" { + ( + "host.http.do_stream", + "host.http.stream_read", + 200, + json!("body"), + json!({"eof":false}), + ) + } else { + ( + "host.upstream.websocket.open", + "host.upstream.websocket.read", + 101, + Value::Null, + json!({"eof":false,"kind":"text"}), + ) + }; + let id = callback_id(&mut host, open).await; + reply( + &mut host, + id, + json!({"status":status,"headers":[],"stream":stream}), + b"", + ) + .await; + let first = callback_id(&mut host, read).await; + signals.cancel.notify_one(); + signals.dropped.notified().await; + // 已取消的读取随后成功,下一次 read 应恢复同一读取结果。 + reply(&mut host, first, data.clone(), b"first").await; + signals.resume.notify_one(); + let next = session::receive(&mut host).await; + let result = if let Message::Callback { id, method, .. } = next.message { + assert_eq!(method, read); + reply(&mut host, id, data, b"second").await; + session::receive(&mut host).await + } else { + next + }; + assert!(matches!(result.message, Message::Result { id: 1, .. })); + session::shutdown(&mut host, task).await; + assert_eq!( + result.payload, b"first", + "取消 read 的等待不应丢弃第一段数据" + ); +} +#[tokio::test] +async fn http_read_cancellation_keeps_first_chunk() { + verify_cancelled_read("http").await; +} +#[tokio::test] +async fn upstream_websocket_cancellation_keeps_first_message() { + verify_cancelled_read("websocket").await; +} + +impl Signals { + async fn interrupt(&self, read: impl Future) { + tokio::select! { + _ = self.cancel.notified() => {}, + _ = read => panic!("未取消前不得返回数据"), + } + self.dropped.notify_one(); + self.resume.notified().await; + } +} + +#[derive(Clone, Copy)] +enum Action { + HttpRead, + HttpForward, + WebSocketRead, + WebSocketTransfer, + ModelFacts, +} + +struct MiddlewareReader { + signals: Arc, + action: Action, +} + +impl PluginHandler for MiddlewareReader { + fn call(&self, call: PluginCall) -> CallFuture<'_> { + Box::pin(async move { + let bytes = match self.action { + Action::HttpRead | Action::HttpForward => { + let mut call = HttpCall::decode(call)?; + self.signals.interrupt(call.request.body.read()).await; + if matches!(self.action, Action::HttpForward) { + return HttpResponse::new(200, call.request.body).into_reply(); + } + let Some(HttpFrame::Data(bytes)) = call.request.body.read().await? else { + panic!("应保留数据帧") + }; + bytes + } + Action::WebSocketRead | Action::WebSocketTransfer => { + let mut call = WebSocketCall::decode(call)?; + self.signals.interrupt(call.message.payload.read()).await; + if matches!(self.action, Action::WebSocketTransfer) { + return Some(call.message).encode(); + } + call.message.payload.read().await?.unwrap() + } + Action::ModelFacts => { + let call = RequestCall::decode(call)?; + let response = call.next.run(call.request).await?; + let mut body = response.body.with_facts()?; + self.signals.interrupt(body.read()).await; + let frame = body.read().await?.unwrap(); + assert_eq!(frame.source_id(), 1); + assert!(frame.facts.is_none()); + frame.payload + } + }; + Ok(CallReply::unary(json!({}), bytes)) + }) + } +} + +async fn start_middleware( + action: Action, +) -> ( + session::HostPeer, + tokio::task::JoinHandle>, + Arc, +) { + let signals = Arc::new(Signals::default()); + let (mut host, task) = session::start_session(MiddlewareReader { + signals: signals.clone(), + action, + }) + .await; + let (stage, params) = match action { + Action::HttpRead | Action::HttpForward => ( + Stage::Http, + json!({"settings_sources":null,"request_id":"request", "call_id":"http-call", "parent_call_id":null, "request": { + "settings":null, + "method":"POST", "uri":"/test", "version":"HTTP/1.1", + "headers":[], "timeout_ms":null, "body":{"kind":"handle","handle":"body"} + }}), + ), + Action::WebSocketRead | Action::WebSocketTransfer => ( + Stage::WebSocket, + json!({"connection_id":"connection", "direction":"incoming", "headers":[], + "message":{"kind":{"kind":"text"}, "payload":{"kind":"handle","handle":"body"}} + }), + ), + Action::ModelFacts => ( + Stage::Request, + serde_json::to_value(session::middleware_request()).unwrap(), + ), + }; + let mut context = session::context(1, Duration::from_secs(2)); + context.stage = stage; + context.request_id = params["request_id"].as_str().map(str::to_owned); + write_frame( + &mut host.writer, + &Frame::control(Message::Call { + id: 1, + method: HANDLE_METHOD.into(), + context, + params, + }), + ) + .await + .unwrap(); + (host, task, signals) +} + +#[tokio::test] +async fn middleware_http_and_websocket_reads_resume_the_pending_chunk() { + for action in [Action::HttpRead, Action::WebSocketRead] { + let (mut host, task, signals) = start_middleware(action).await; + let method = if matches!(action, Action::HttpRead) { + gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD + } else { + BODY_READ_METHOD + }; + let id = callback_id(&mut host, method).await; + signals.cancel.notify_one(); + signals.dropped.notified().await; + reply(&mut host, id, json!({"eof":false}), b"first").await; + signals.resume.notify_one(); + let frame = session::receive(&mut host).await; + assert!(matches!(frame.message, Message::Result { id: 1, .. })); + assert_eq!(frame.payload, b"first"); + session::shutdown(&mut host, task).await; + } +} + +#[tokio::test] +async fn model_frame_survives_cancellation_while_fetching_its_facts() { + let (mut host, task, signals) = start_middleware(Action::ModelFacts).await; + let id = callback_id(&mut host, NEXT_METHOD).await; + reply( + &mut host, + id, + json!({"response":"response", "protocol":"openai", "status":200, + "body":{"handle":"body", "framing":"sse_event"}}), + b"", + ) + .await; + let id = callback_id(&mut host, BODY_READ_METHOD).await; + reply( + &mut host, + id, + json!({"framing":"sse_event", "source_id":1, "eof":false, "terminal":false}), + b"first", + ) + .await; + let id = callback_id(&mut host, BODY_FACTS_METHOD).await; + signals.cancel.notify_one(); + signals.dropped.notified().await; + reply(&mut host, id, json!({"present":false}), b"").await; + signals.resume.notify_one(); + let frame = session::receive(&mut host).await; + assert!(matches!(frame.message, Message::Result { id: 1, .. })); + assert_eq!(frame.payload, b"first"); + session::shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn http_forwarding_retains_a_pending_read_under_output_backpressure() { + let (mut host, task, signals) = start_middleware(Action::HttpForward).await; + let id = callback_id( + &mut host, + gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD, + ) + .await; + signals.cancel.notify_one(); + signals.dropped.notified().await; + reply(&mut host, id, json!({"eof":false}), b"first").await; + signals.resume.notify_one(); + write_frame( + &mut host.writer, + &Frame::control(Message::Credit { + id: 1, + bytes: 1024, + frames: 2, + }), + ) + .await + .unwrap(); + let frame = session::receive(&mut host).await; + assert!( + matches!(frame.message, Message::Result { id: 1, result } if result["body"]["kind"] == "stream") + ); + let frame = session::receive(&mut host).await; + assert!(matches!( + frame.message, + Message::Stream { id: 1, sequence: 0 } + )); + assert_eq!(frame.payload, b"\0first"); + let id = callback_id( + &mut host, + gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD, + ) + .await; + reply(&mut host, id, json!({"eof":true}), b"").await; + assert!(matches!( + session::receive(&mut host).await.message, + Message::End { id: 1, error: None } + )); + session::shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn pending_http_read_is_split_when_credit_arrives_after_the_callback() { + let (mut host, task, signals) = start_middleware(Action::HttpForward).await; + let id = callback_id( + &mut host, + gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD, + ) + .await; + signals.cancel.notify_one(); + signals.dropped.notified().await; + let expected = vec![b'a'; 8192]; + reply(&mut host, id, json!({"eof":false}), &expected).await; + signals.resume.notify_one(); + write_frame( + &mut host.writer, + &Frame::control(Message::Credit { + id: 1, + bytes: 1024, + frames: 2, + }), + ) + .await + .unwrap(); + assert!(matches!( + session::receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + let mut body = Vec::new(); + loop { + let frame = session::receive(&mut host).await; + match frame.message { + Message::Stream { id: 1, .. } => { + assert!(frame.payload.len() <= 1024); + assert_eq!(frame.payload[0], 0); + body.extend_from_slice(&frame.payload[1..]); + write_frame( + &mut host.writer, + &Frame::control(Message::Credit { + id: 1, + bytes: frame.payload.len() as u32, + frames: 1, + }), + ) + .await + .unwrap(); + } + Message::Callback { id, method, .. } => { + assert_eq!( + method, + gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD + ); + assert_eq!(body, expected); + reply(&mut host, id, json!({"eof":true}), b"").await; + } + Message::End { id: 1, error: None } => break, + message => panic!("unexpected stream message: {message:?}"), + } + } + assert_eq!(body, expected); + session::shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn websocket_transfer_rejects_a_payload_with_a_pending_read() { + let (mut host, task, signals) = start_middleware(Action::WebSocketTransfer).await; + let _id = callback_id(&mut host, BODY_READ_METHOD).await; + signals.cancel.notify_one(); + signals.dropped.notified().await; + signals.resume.notify_one(); + assert!(matches!(session::receive(&mut host).await.message, + Message::Error { id: 1, error } if error.code == ErrorCode::InvalidInput)); + session::shutdown(&mut host, task).await; +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session.rs b/backend/crates/gateway-plugin/sdk/tests/client/session.rs index 4c225c643..35811c751 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session.rs @@ -19,8 +19,8 @@ use gateway_plugin_sdk::{ MiddlewareResponseHead, MiddlewareTransport, NEXT_METHOD, }, client::{ - CallFuture, CallReply, MiddlewareCall, MiddlewarePlugin, PluginCall, PluginHandler, - PluginSession, ResponseStream, SessionConfig, SessionError, read_frame, write_frame, + CallFuture, CallReply, MiddlewarePlugin, PluginCall, PluginHandler, PluginSession, + RequestCall, ResponseStream, SessionConfig, SessionError, read_frame, write_frame, }, }; use serde_json::{Value, json}; @@ -220,6 +220,11 @@ impl PluginHandler for TestHandler { .map_err(SessionError::into_plugin_fault)?; Ok(CallReply::unary(reply.result, reply.payload)) } + "resource_stream" => Ok(CallReply::stream( + json!({}), + Vec::new(), + ResponseStream::pull(Box::new(ResourceStream(Some(call.host)))), + )), "stream" => Ok(CallReply::stream( json!({"stream": true}), Vec::new(), @@ -362,7 +367,7 @@ fn middleware_contributions() -> Contributions { Capability::Middleware, ContributionDeclaration { id: "test.example.middleware".into(), - version: 1, + version: 3, stages: vec![Stage::Request], input_formats: vec!["openai".into()], output_formats: vec!["openai".into()], @@ -373,8 +378,8 @@ fn middleware_contributions() -> Contributions { fn middleware_plugin(map_response: bool) -> impl PluginHandler { MiddlewarePlugin::new( &middleware_contributions(), - move |call: MiddlewareCall| async move { - let MiddlewareCall { + move |call: RequestCall| async move { + let RequestCall { mut request, next, .. } = call; if map_response { @@ -418,7 +423,7 @@ fn middleware_plugin_rejects_declarations_without_matching_handlers() { (Capability::Scheduler, declaration.clone()), ])); let mut unsupported = declaration.clone(); - unsupported.version = 3; + unsupported.version = 2; invalid.push(Contributions::from([(Capability::Middleware, unsupported)])); for stages in [ vec![], @@ -434,7 +439,7 @@ fn middleware_plugin_rejects_declarations_without_matching_handlers() { )])); } for contributes in invalid { - let result = MiddlewarePlugin::new(&contributes, |call: MiddlewareCall| async move { + let result = MiddlewarePlugin::new(&contributes, |call: RequestCall| async move { call.next.run(call.request).await }); assert!(matches!(result, Err(SessionError::Configuration))); @@ -443,7 +448,7 @@ fn middleware_plugin_rejects_declarations_without_matching_handlers() { both_mounts.stages.push(Stage::Attempt); let contributes = Contributions::from([(Capability::Middleware, both_mounts)]); assert!( - MiddlewarePlugin::new(&contributes, |call: MiddlewareCall| async move { + MiddlewarePlugin::new(&contributes, |call: RequestCall| async move { call.next.run(call.request).await }) .is_ok() @@ -453,7 +458,7 @@ fn middleware_plugin_rejects_declarations_without_matching_handlers() { #[tokio::test] async fn middleware_plugin_registers_without_invoking_business_handler() { let contributes = middleware_contributions(); - let plugin = MiddlewarePlugin::new(&contributes, |_| async { + let plugin = MiddlewarePlugin::new(&contributes, |_: RequestCall| async { panic!("registration must not invoke middleware") }) .unwrap(); @@ -487,7 +492,7 @@ async fn middleware_plugin_registers_without_invoking_business_handler() { #[tokio::test] async fn middleware_plugin_rejects_invalid_calls_before_business_dispatch() { - let plugin = MiddlewarePlugin::new(&middleware_contributions(), |_| async { + let plugin = MiddlewarePlugin::new(&middleware_contributions(), |_: RequestCall| async { panic!("invalid call must not invoke middleware") }) .unwrap(); @@ -554,12 +559,12 @@ fn handshake() -> Handshake { generation: 7, incarnation: "test-incarnation".into(), configuration: json!({}), - permissions: Vec::new(), + contributes: Contributions::new(), } } -fn context(id: u64, timeout: Duration) -> CallContext { +pub(super) fn context(id: u64, timeout: Duration) -> CallContext { CallContext { call_id: id, instance_id: "test-instance".into(), @@ -567,6 +572,7 @@ fn context(id: u64, timeout: Duration) -> CallContext { incarnation: "test-incarnation".into(), stage: Stage::Request, timeout_ms: timeout.as_millis() as u64, + resource_stream: false, resource_scope_id: format!("scope-{id}"), request_id: None, attempt_id: None, @@ -583,8 +589,12 @@ fn middleware_context(id: u64) -> CallContext { } } -fn middleware_request() -> MiddlewareRequestHead { +pub(super) fn middleware_request() -> MiddlewareRequestHead { MiddlewareRequestHead { + settings_sources: serde_json::Value::Null, + settings: serde_json::Value::Null, + client_key_id: "fixture-key".into(), + account_group_ids: vec!["fixture-group".into()], request_id: "request-middleware".into(), mount: MiddlewareMount::Request, attempt_index: None, @@ -599,7 +609,6 @@ fn middleware_request() -> MiddlewareRequestHead { name: "x-old".into(), value: b"old".to_vec(), }], - body_visible: true, } } @@ -949,6 +958,7 @@ async fn middleware_next_and_body_mapping_reuse_callback_and_credit_flow() { message: Message::Result { id: next_id, result: serde_json::to_value(MiddlewareNextResponse { + metadata: None, response: "response-1".into(), protocol: "openai".into(), status: 200, @@ -1116,6 +1126,7 @@ async fn untouched_middleware_response_transfers_opaque_body_without_reading_it( message: Message::Result { id: next_id, result: serde_json::to_value(MiddlewareNextResponse { + metadata: None, response: "response-1".into(), protocol: "openai".into(), status: 200, @@ -1676,3 +1687,72 @@ async fn typed_key_and_quota_calls_keep_payloads_and_do_not_retry_failures() { } } } + +struct ResourceStream(Option); +impl gateway_plugin_sdk::client::PullResponseStream for ResourceStream { + fn next(&mut self) -> gateway_plugin_sdk::client::PullResponseFuture<'_> { + Box::pin(async move { + let host = self.0.take()?; + tokio::time::sleep(Duration::from_millis(700)).await; + Some( + host.call("host.log", json!({}), Vec::new()) + .await + .map(|_| b"live".to_vec()) + .map_err(SessionError::into_plugin_fault), + ) + }) + } +} + +#[tokio::test] +async fn resource_stream_keeps_callbacks_and_credit_alive_after_the_initial_deadline() { + let (mut host, task) = start_session(TestHandler::default()).await; + let mut context = context(1, Duration::from_millis(500)); + context.resource_stream = true; + write_frame( + &mut host.writer, + &Frame::control(Message::Call { + id: 1, + method: "resource_stream".into(), + context, + params: json!({}), + }), + ) + .await + .unwrap(); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 16, + frames: 2, + }, + ) + .await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + let Message::Callback { + id, parent_id: 1, .. + } = receive(&mut host).await.message + else { + panic!("stream callback must survive initial timeout"); + }; + send_control( + &mut host, + Message::Result { + id, + result: json!({}), + }, + ) + .await; + assert!( + matches!(receive(&mut host).await, Frame { message: Message::Stream { id: 1, sequence: 0 }, payload } if payload == b"live") + ); + assert!(matches!( + receive(&mut host).await.message, + Message::End { id: 1, error: None } + )); + shutdown(&mut host, task).await; +} diff --git a/backend/crates/gateway-plugin/sdk/tests/manifest.rs b/backend/crates/gateway-plugin/sdk/tests/manifest.rs index adafaf2bc..e3038eff6 100644 --- a/backend/crates/gateway-plugin/sdk/tests/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/tests/manifest.rs @@ -7,7 +7,7 @@ use serde_json::json; fn source_manifest() -> Manifest { serde_json::from_value(json!({ - "manifestVersion": 1, + "manifestVersion":2, "name": "request-tags", "displayName": "请求标签", "publisher": "9acme", @@ -21,7 +21,7 @@ fn source_manifest() -> Manifest { "contributes": { "middleware": { "id": "9acme.request-tags.tagRequest", - "version": 1, + "version": 3, "stages": ["request"], "inputFormats": ["openai"], "outputFormats": ["openai"] @@ -94,7 +94,7 @@ fn author_manifest_fills_default_ids_versions_and_fixed_stages() { "main": "bin/worker", "runtime": "trustedProcess", "contributes": { - "middleware": {"stages": ["attempt"]}, + "middleware": {"version": 3, "stages": ["attempt"]}, "management": {} } }); @@ -103,7 +103,7 @@ fn author_manifest_fills_default_ids_versions_and_fixed_stages() { let management = &manifest.contributes[&Capability::Management]; assert_eq!(middleware.id, "9acme.request-tags.middleware"); - assert_eq!(middleware.version, 1); + assert_eq!(middleware.version, 3); assert_eq!(middleware.stages, [Stage::Attempt]); assert_eq!(management.id, "9acme.request-tags.management"); assert_eq!(management.version, 1); @@ -119,7 +119,7 @@ fn installation_checks_protocol_engine_and_target() { manifest.package_for(&host, "windows", "aarch64"), Err(ManifestError::Platform) ); - manifest.package.as_mut().unwrap().protocol_version = 2; + manifest.package.as_mut().unwrap().protocol_version = PROTOCOL_VERSION + 1; assert_eq!(manifest.validate(), Err(ManifestError::Incompatible)); manifest.package.as_mut().unwrap().protocol_version = PROTOCOL_VERSION; manifest.engines.codex_proxy_rs = "*".parse().unwrap(); @@ -129,15 +129,8 @@ fn installation_checks_protocol_engine_and_target() { #[test] fn capability_contract_versions_are_checked_independently_of_rpc_version() { let mut manifest = packaged_manifest(); - for version in [1, 2] { - manifest - .contributes - .get_mut(&Capability::Middleware) - .unwrap() - .version = version; - assert!(manifest.validate().is_ok()); - } - for version in [0, 3, u32::MAX] { + assert!(manifest.validate().is_ok()); + for version in [0, 1, 2, 4, u32::MAX] { manifest .contributes .get_mut(&Capability::Middleware) @@ -368,12 +361,12 @@ fn public_manifest_fields_use_only_the_v3_camel_case_shape() { #[test] fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() { let declaration = r#"{ - "id":"test.example.middleware","version":1,"stages":["request"], + "id":"test.example.middleware","version":3,"stages":["request"], "inputFormats":["openai"],"outputFormats":["openai"] }"#; let manifest = format!( r#"{{ - "manifestVersion":1,"name":"example","displayName":"Example","publisher":"test", + "manifestVersion":2,"name":"example","displayName":"Example","publisher":"test", "version":"1.0.0","description":"Example","license":"MIT", "engines":{{"codex-proxy-rs":"*"}},"main":"bin/worker","runtime":"trustedProcess", "contributes":{{"middleware":{declaration},"middleware":{declaration}}} @@ -389,7 +382,7 @@ 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":{{}},"permissions":[], + "configuration":{{}}, "contributes":{{"middleware":{declaration},"middleware":{declaration}}} }}"# ); @@ -404,7 +397,7 @@ fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() { fn contribution_declaration_uses_camel_case_description_fields() { let declaration = ContributionDeclaration { id: "test.example.middleware".into(), - version: 1, + version: 3, stages: vec![Stage::Request], input_formats: vec!["openai".into()], output_formats: vec!["openai".into()], @@ -413,7 +406,7 @@ fn contribution_declaration_uses_camel_case_description_fields() { serde_json::to_value(declaration).unwrap(), json!({ "id":"test.example.middleware", - "version":1, + "version":3, "stages":["request"], "inputFormats":["openai"], "outputFormats":["openai"] diff --git a/backend/crates/gateway-plugin/sdk/tests/message.rs b/backend/crates/gateway-plugin/sdk/tests/message.rs index f27fabc80..f48c28ec4 100644 --- a/backend/crates/gateway-plugin/sdk/tests/message.rs +++ b/backend/crates/gateway-plugin/sdk/tests/message.rs @@ -3,7 +3,14 @@ use gateway_plugin_sdk::{Frame, Message}; #[test] fn diagnostics_do_not_render_peer_payloads_or_error_details() { let secret = "sensitive-fixture-value"; - let fault = gateway_plugin_sdk::PluginFault::new(gateway_plugin_sdk::ErrorCode::Fault, secret); + let mut fault = + gateway_plugin_sdk::PluginFault::new(gateway_plugin_sdk::ErrorCode::Fault, secret); + fault.details = Some( + serde_json::json!({"credential":secret,"empty":"","disabled":false,"zero":0,"missing":null}), + ); + let encoded = serde_json::to_vec(&fault).unwrap(); + let decoded: gateway_plugin_sdk::PluginFault = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded, fault); let frame = Frame { message: Message::Result { id: 1, diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index aa46521e1..82f328884 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -81,13 +81,10 @@ impl SettingsStore for AdminSettingsStoreAdapter { command: ReplaceRuntimeSettings, context: &MutationContext, ) -> AdminStoreResult { - let current = postgres::ControlPlaneRepository::load_control_plane(&self.control_plane) - .await - .map_err(|error| admin_store_error("runtime settings", error))?; let replacement = postgres::ControlPlaneReplacement { + expected_revision: store_revision(command.expected_revision)?, settings: postgres::RuntimeSettingsUpdate { request_profile_updates: command.request_profile_updates, - admin_api_key: current.settings.admin_api_key, refresh_margin_seconds: command.refresh_margin_seconds, refresh_concurrency: command.refresh_concurrency, max_concurrent_per_account: command.max_concurrent_per_account, diff --git a/backend/crates/gateway-store/src/postgres/client_budgets.rs b/backend/crates/gateway-store/src/postgres/client_budgets.rs index 3a6b7a060..045f8cdb9 100644 --- a/backend/crates/gateway-store/src/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/src/postgres/client_budgets.rs @@ -36,7 +36,7 @@ pub(super) async fn reset_client_key_budget( ) })?, ClientKeyBudgetMutationOrigin::Plugin(owner) => { - super::plugins::begin_authorized_mutation(pool, owner, "key_budgets").await? + super::plugins::begin_plugin_mutation(pool, owner).await? } }; reset_client_key_budget_in_transaction(&mut tx, &command, context) diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index 0e56fefdc..8c5f7dae6 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -724,8 +724,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .await .map_err(|_| map_error(postgres_unavailable("begin budget limits update")))?, ClientKeyBudgetMutationOrigin::Plugin(owner) => { - super::plugins::begin_authorized_mutation(&self.keys.pool, owner, "key_budgets") - .await? + super::plugins::begin_plugin_mutation(&self.keys.pool, owner).await? } }; // 保持与完整 Key 编辑相同的锁顺序;绝不读出整份配置再覆盖写回。 diff --git a/backend/crates/gateway-store/src/postgres/mod.rs b/backend/crates/gateway-store/src/postgres/mod.rs index 011e3463b..7aa9f65af 100644 --- a/backend/crates/gateway-store/src/postgres/mod.rs +++ b/backend/crates/gateway-store/src/postgres/mod.rs @@ -149,6 +149,7 @@ pub struct ControlPlaneSnapshot { #[derive(Debug, Clone)] pub struct ControlPlaneReplacement { + pub expected_revision: Revision, pub settings: RuntimeSettingsUpdate, pub audit: AdminAuditEvent, } @@ -225,6 +226,20 @@ impl ControlPlaneRepository for PgControlPlaneRepository { .await .map_err(|_| postgres_unavailable("begin control plane replacement"))?; let result = async { + // 锁住所有配置写入共同使用的行,版本检查与修改不能被其他事务穿插。 + let current = sqlx::query_scalar::<_, i64>( + "select config_revision from runtime_settings where id = 1 for update", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| postgres_unavailable("lock control plane revision"))?; + if u64::try_from(current).ok() != Some(replacement.expected_revision.get()) { + return Err(StoreError::Conflict { + entity: "runtime settings", + id: "1".to_owned(), + kind: crate::ConflictKind::StaleRevision, + }); + } let revision = update_runtime_settings_in_transaction(&mut transaction, &replacement.settings) .await?; diff --git a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs index dfc79c2f0..e69694b30 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs @@ -54,13 +54,9 @@ pub(super) fn not_found() -> AdminStoreError { pub(super) fn decode_metadata( row: &sqlx::postgres::PgRow, ) -> AdminStoreResult { - let mut metadata: serde_json::Value = + let metadata: sqlx::types::Json = row.try_get("metadata_json").map_err(|_| unavailable())?; - // 兼容开发期已写入的展示说明;忽略这一旧字段,其他未知字段仍严格拒绝。 - if let Some(metadata) = metadata.as_object_mut() { - metadata.remove("permissionDescriptions"); - } - serde_json::from_value(metadata).map_err(|_| unavailable()) + Ok(metadata.0) } fn decode(row: &sqlx::postgres::PgRow) -> AdminStoreResult { @@ -322,7 +318,7 @@ impl PluginStore for PgPluginStore { "accept", "plugin_artifact", digest, - vec!["permissions".into()], + vec!["acceptedAt".into()], ), revision, ) diff --git a/backend/crates/gateway-store/src/postgres/plugins/instances.rs b/backend/crates/gateway-store/src/postgres/plugins/instances.rs index 710af3669..56100ffbf 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/instances.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/instances.rs @@ -6,7 +6,7 @@ use gateway_admin::{ plugins::{ instances::{ PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, - PluginInstanceSnapshot, PluginPermissionGrant, PluginVersionConfiguration, + PluginInstanceSnapshot, PluginVersionConfiguration, }, state::PluginStateCommit, }, @@ -106,7 +106,6 @@ fn decode(row: &sqlx::postgres::PgRow) -> AdminStoreResult { .try_get::>, _>("accepted_at") .map_err(|_| unavailable())? .is_some(); - let metadata = super::artifacts::decode_metadata(row)?; Ok(PluginInstance { id: id.to_string(), name: row.try_get("name").map_err(|_| unavailable())?, @@ -120,15 +119,6 @@ fn decode(row: &sqlx::postgres::PgRow) -> AdminStoreResult { .try_get::, _>("secrets_json") .map_err(|_| unavailable())? .0, - grants: if accepted { - metadata - .requested_permissions - .into_iter() - .map(|permission| PluginPermissionGrant { permission }) - .collect() - } else { - Vec::new() - }, bindings: row .try_get::, _>("bindings_json") .map_err(|_| unavailable())? @@ -164,20 +154,11 @@ async fn validate_artifact_acceptance( .await .map_err(|_| unavailable())? .ok_or_else(not_found)?; - let metadata = super::artifacts::decode_metadata(&row)?; let accepted = row .try_get::>, _>("accepted_at") .map_err(|_| unavailable())? .is_some(); - let expected_grants = metadata - .requested_permissions - .into_iter() - .map(|permission| PluginPermissionGrant { permission }) - .collect::>(); - if instance.trusted_process != accepted - || instance.grants != expected_grants - || (instance.enabled && !accepted) - { + if instance.trusted_process != accepted || (instance.enabled && !accepted) { return Err(AdminStoreError::new( AdminStoreErrorKind::Invalid, "plugin", diff --git a/backend/crates/gateway-store/src/postgres/plugins/mod.rs b/backend/crates/gateway-store/src/postgres/plugins/mod.rs index eea7d4f60..90ae9ed03 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/mod.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/mod.rs @@ -1,11 +1,11 @@ mod artifacts; -mod authorization; mod credentials; mod instances; +mod mutation; mod resources; mod sources; mod state; pub use artifacts::PgPluginStore; -pub(super) use authorization::begin_authorized_mutation; +pub(super) use mutation::begin_plugin_mutation; diff --git a/backend/crates/gateway-store/src/postgres/plugins/authorization.rs b/backend/crates/gateway-store/src/postgres/plugins/mutation.rs similarity index 82% rename from backend/crates/gateway-store/src/postgres/plugins/authorization.rs rename to backend/crates/gateway-store/src/postgres/plugins/mutation.rs index 3109fa56c..4e2694c25 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/authorization.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/mutation.rs @@ -1,4 +1,4 @@ -//! 插件管理写入的事务授权;与实例修改共用控制面锁,避免检查后权限被撤销。 +//! 插件写入与实例发布共用控制面锁,避免一个事务混用不同配置版本。 use gateway_admin::{ model::plugin_resources::PluginResourceOwner, @@ -18,14 +18,13 @@ fn mutation_denied() -> AdminStoreError { AdminStoreError::new( AdminStoreErrorKind::Conflict, "plugin mutation", - "plugin instance authorization changed", + "plugin instance revision changed", ) } -pub(in crate::postgres) async fn begin_authorized_mutation<'a>( +pub(in crate::postgres) async fn begin_plugin_mutation<'a>( pool: &'a PgPool, owner: &PluginResourceOwner, - permission: &str, ) -> AdminStoreResult> { let mut tx = pool.begin().await.map_err(|_| mutation_unavailable())?; // 与全部管理写入保持相同锁顺序;无变化的对账只锁定,不递增 revision。 @@ -40,13 +39,12 @@ pub(in crate::postgres) async fn begin_authorized_mutation<'a>( select 1 from plugin_instances i join plugin_artifacts a on a.sha256=i.artifact_sha256 where i.id=$1 and i.enabled and i.revision=$2 and i.artifact_sha256=$3 - and a.accepted_at is not null and a.metadata_json->'requestedPermissions' ? $4 + and a.accepted_at is not null )", ) .bind(id) .bind(revision) .bind(&owner.artifact_sha256) - .bind(permission) .fetch_one(&mut *tx) .await .map_err(|_| mutation_unavailable())?; diff --git a/backend/crates/gateway-store/src/postgres/plugins/resources.rs b/backend/crates/gateway-store/src/postgres/plugins/resources.rs index 8720bdd2d..f409c1747 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/resources.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/resources.rs @@ -110,7 +110,7 @@ impl PluginResourceStore for PgPluginStore { context: &MutationContext, ) -> AdminStoreResult> { validate_resource_key(&resource_key)?; - let mut tx = super::begin_authorized_mutation(&self.pool, owner, "groups").await?; + let mut tx = super::begin_plugin_mutation(&self.pool, owner).await?; let instance = uuid::Uuid::parse_str(&owner.instance_id).map_err(|_| denied())?; if let Some(row) = sqlx::query( "select g.id,g.name,g.enabled from plugin_group_resources r @@ -168,7 +168,7 @@ impl PluginResourceStore for PgPluginStore { for group in &groups { validate_resource_key(group)?; } - let mut tx = super::begin_authorized_mutation(&self.pool, owner, "keys").await?; + let mut tx = super::begin_plugin_mutation(&self.pool, owner).await?; let instance = uuid::Uuid::parse_str(&owner.instance_id).map_err(|_| denied())?; if let Some(row) = sqlx::query( "select k.id,k.name,k.enabled from plugin_key_resources r @@ -238,7 +238,7 @@ impl PluginResourceStore for PgPluginStore { { return Err(invalid()); } - let mut tx = super::begin_authorized_mutation(&self.pool, owner, "groups").await?; + let mut tx = super::begin_plugin_mutation(&self.pool, owner).await?; let instance = uuid::Uuid::parse_str(&owner.instance_id).map_err(|_| denied())?; let group: String = sqlx::query_scalar( "select group_id from plugin_group_resources where instance_id=$1 and resource_key=$2", diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index 349f81df7..4ea94f049 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -125,7 +125,6 @@ pub struct RuntimeSettingsUpdate { gateway_core::routing::ProviderKind, Option, >, - pub admin_api_key: Option, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, pub max_concurrent_per_account: u32, @@ -160,10 +159,6 @@ impl fmt::Debug for RuntimeSettingsUpdate { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("RuntimeSettingsUpdate") - .field( - "admin_api_key", - &self.admin_api_key.as_ref().map(|_| "[REDACTED]"), - ) .field("rotation_strategy", &self.rotation_strategy) .field("request_location_enabled", &self.request_location_enabled) .field("request_location", &self.request_location) @@ -465,41 +460,39 @@ pub(crate) async fn update_runtime_settings_in_transaction( let next = sqlx::query_scalar::<_, i64>( "update runtime_settings set config_revision = config_revision + 1, - admin_api_key = $1, - refresh_margin_seconds = $2, - refresh_concurrency = $3, - max_concurrent_per_account = $4, - request_interval_ms = $5, - rotation_strategy = $6, - model_mappings_json = $7, - usage_retention_days = $8, - ops_event_retention_days = $9, - audit_retention_days = $10, - min_codex_desktop_version = $11, - min_codex_cli_version = $12, - max_waiting_per_key = $13, - max_waiting_per_account = $14, - concurrency_wait_timeout_seconds = $15, - account_auto_freeze_enabled = $16, - account_auto_freeze_threshold = $17, - account_auto_freeze_window_seconds = $18, - account_auto_freeze_duration_seconds = $19, - account_auto_freeze_probe_enabled = $20, - account_auto_freeze_probe_model = $21, - account_auto_freeze_adaptive_concurrency = $22, - request_location_json = $23, - request_location_enabled = $24, - responses_max_decompressed_body_bytes = $25, - provider_request_profiles_json = (provider_request_profiles_json - $26::text[]) || $27::jsonb, - account_warmup_enabled = $28, - account_warmup_schedule_time = $29, - account_warmup_model = $30, - smart_scheduling_json = $31, + refresh_margin_seconds = $1, + refresh_concurrency = $2, + max_concurrent_per_account = $3, + request_interval_ms = $4, + rotation_strategy = $5, + model_mappings_json = $6, + usage_retention_days = $7, + ops_event_retention_days = $8, + audit_retention_days = $9, + min_codex_desktop_version = $10, + min_codex_cli_version = $11, + max_waiting_per_key = $12, + max_waiting_per_account = $13, + concurrency_wait_timeout_seconds = $14, + account_auto_freeze_enabled = $15, + account_auto_freeze_threshold = $16, + account_auto_freeze_window_seconds = $17, + account_auto_freeze_duration_seconds = $18, + account_auto_freeze_probe_enabled = $19, + account_auto_freeze_probe_model = $20, + account_auto_freeze_adaptive_concurrency = $21, + request_location_json = $22, + request_location_enabled = $23, + responses_max_decompressed_body_bytes = $24, + provider_request_profiles_json = (provider_request_profiles_json - $25::text[]) || $26::jsonb, + account_warmup_enabled = $27, + account_warmup_schedule_time = $28, + account_warmup_model = $29, + smart_scheduling_json = $30, updated_at = now() where id = 1 returning config_revision", ) - .bind(update.admin_api_key.as_deref()) .bind(refresh_margin_seconds) .bind(i64::from(update.refresh_concurrency)) .bind(i64::from(update.max_concurrent_per_account)) diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index 86fa9bcd4..f0d1f43de 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -8,10 +8,10 @@ use gateway_core::routing::{ AccountGroupId, ConfigRevision, snapshot::{ SnapshotAccountGroupFacts, SnapshotAccountGroupMemberFacts, SnapshotClientPolicyFacts, - SnapshotFacts, SnapshotProviderAccountFacts, SnapshotSettingsFacts, SnapshotStoreError, - SnapshotStorePort, + SnapshotFacts, SnapshotProviderAccountFacts, SnapshotStoreError, SnapshotStorePort, }, }; +use gateway_core::settings::SettingsValues; use sqlx::{PgPool, Postgres, Transaction}; use crate::{Revision, StoreError, StoreResult, postgres_unavailable}; @@ -152,7 +152,7 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { .map_err(|_| SnapshotStoreError::unavailable())?; let config_revision = core_revision(data.config_revision)?; let observed_current_revision = core_revision(data.observed_current_revision)?; - let settings = SnapshotSettingsFacts::new( + let settings = SettingsValues::new( data.settings.max_concurrent_per_account, data.settings.request_interval_ms, data.settings.rotation_strategy, diff --git a/backend/crates/gateway-store/tests/postgres/client_budgets.rs b/backend/crates/gateway-store/tests/postgres/client_budgets.rs index f766d44dc..771eddaad 100644 --- a/backend/crates/gateway-store/tests/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/tests/postgres/client_budgets.rs @@ -9,10 +9,7 @@ use gateway_admin::{ UpdateClientKey, UpdateClientKeyBudgetLimits, }, plugin_resources::PluginResourceOwner, - plugins::{ - PluginSource, - instances::{PluginInstance, PluginPermissionGrant}, - }, + plugins::{PluginSource, instances::PluginInstance}, }, ports::{ plugins::PluginStore as _, @@ -582,8 +579,7 @@ async fn budget_database_outage_fails_closed() { async fn plugin_reset_owner(database: &TestDatabase) -> PluginResourceOwner { super::plugins::artifacts::initialize_revision(database).await; let store = PgPluginStore::new(database.pool.clone()); - let mut package = super::plugins::artifacts::artifact('b', &["linux-x86_64"]); - package.metadata.requested_permissions = vec!["key_budgets".into()]; + let package = super::plugins::artifacts::artifact('b', &["linux-x86_64"]); let installed = store .install_artifact(package, PluginSource::Upload, &context()) .await @@ -602,9 +598,7 @@ async fn plugin_reset_owner(database: &TestDatabase) -> PluginResourceOwner { trusted_process: true, configuration: serde_json::json!({}), secrets: BTreeMap::new(), - grants: vec![PluginPermissionGrant { - permission: "key_budgets".into(), - }], + bindings: vec![], revision: Revision::new(1).unwrap(), }, @@ -682,7 +676,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() { } #[tokio::test] -async fn plugin_reset_revalidates_current_authorization_without_changing_the_ledger() { +async fn plugin_reset_revalidates_current_revision_without_changing_the_ledger() { let Some(database) = TestDatabase::create("plugin_budget_authorization").await else { return; }; @@ -698,7 +692,6 @@ async fn plugin_reset_revalidates_current_authorization_without_changing_the_led "update plugin_instances set revision=revision+1", "update plugin_instances set revision=revision-1, enabled=false", "update plugin_instances set enabled=true; update plugin_artifacts set accepted_at=null", - "update plugin_artifacts set accepted_at=now(), metadata_json=jsonb_set(metadata_json,'{requestedPermissions}','[]'::jsonb)", ] { sqlx::raw_sql(sql).execute(&database.pool).await.unwrap(); assert_eq!( @@ -718,8 +711,10 @@ async fn plugin_reset_revalidates_current_authorization_without_changing_the_led ); assert_eq!(status(&database, "key").await, before); } - sqlx::query("update plugin_artifacts set metadata_json=jsonb_set(metadata_json,'{requestedPermissions}','[\"key_budgets\"]'::jsonb)") - .execute(&database.pool).await.unwrap(); + sqlx::query("update plugin_artifacts set accepted_at=now()") + .execute(&database.pool) + .await + .unwrap(); let wrong_artifact = PluginResourceOwner { artifact_sha256: "c".repeat(64), ..owner.clone() diff --git a/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs index 69956d14a..ef67de6eb 100644 --- a/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs +++ b/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs @@ -38,7 +38,7 @@ pub(in crate::postgres) fn artifact(digest: char, platforms: &[&str]) -> Inspect output_formats: vec!["openai".into()], }, )]), - requested_permissions: vec![], + configuration_schema: serde_json::json!({}), secret_fields: vec![], state_namespaces: vec![], @@ -64,8 +64,8 @@ pub(in crate::postgres) async fn initialize_revision(database: &TestDatabase) { } #[tokio::test] -async fn presentation_text_is_not_persisted_and_legacy_text_does_not_change_artifact_identity() { - let Some(database) = TestDatabase::create("plugin_artifact_presentation").await else { +async fn unknown_metadata_fields_do_not_change_artifact_identity() { + let Some(database) = TestDatabase::create("plugin_metadata_fields").await else { return; }; initialize_revision(&database).await; @@ -82,16 +82,36 @@ async fn presentation_text_is_not_persisted_and_legacy_text_does_not_change_arti .await .unwrap(); assert!(stored.get("permissionDescriptions").is_none()); - sqlx::query("update plugin_artifacts set metadata_json=metadata_json || $2 where sha256=$1") + assert!(stored.get("requestedPermissions").is_none()); + let mut extended = stored; + extended["permissionDescriptions"] = + serde_json::json!([{"permission": "models", "label": "旧文案"}]); + extended["requestedPermissions"] = serde_json::json!(["models"]); + extended["futureMetadata"] = serde_json::json!({"enabled": true}); + extended["contributes"]["middleware"]["futureField"] = serde_json::json!(true); + sqlx::query("update plugin_artifacts set metadata_json=$2 where sha256=$1") .bind(&fixture.metadata.sha256) - .bind(serde_json::json!({"permissionDescriptions": [{"permission": "models", "label": "旧文案", "description": "旧说明"}]})) - .execute(&database.pool).await.unwrap(); + .bind(&extended) + .execute(&database.pool) + .await + .unwrap(); let repeated = store .install_artifact(fixture.clone(), PluginSource::Upload, &context()) .await .unwrap(); assert_eq!(repeated.config_revision, installed.config_revision); assert_eq!(repeated.artifact.metadata, fixture.metadata); + assert_eq!( + store.list_artifacts().await.unwrap(), + vec![installed.artifact] + ); + let stored: serde_json::Value = + sqlx::query_scalar("select metadata_json from plugin_artifacts where sha256=$1") + .bind(&fixture.metadata.sha256) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(stored, extended, "读取和重复安装不得改写未知字段"); assert_eq!( store .load_artifact(&fixture.metadata.sha256) @@ -101,7 +121,7 @@ async fn presentation_text_is_not_persisted_and_legacy_text_does_not_change_arti fixture.metadata ); let mut changed = fixture; - changed.metadata.requested_permissions.push("models".into()); + changed.metadata.description.push_str(" changed"); assert_eq!( store .install_artifact(changed, PluginSource::Upload, &context()) @@ -114,6 +134,94 @@ async fn presentation_text_is_not_persisted_and_legacy_text_does_not_change_arti database.close().await; } +#[tokio::test] +async fn trust_migration_removes_only_obsolete_metadata_fields() { + let Some(database) = TestDatabase::create_through("plugin_trust_upgrade", 19).await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + for (digest, extra) in [ + ('a', serde_json::json!({"requestedPermissions": ["models"]})), + ( + 'b', + serde_json::json!({"permissionDescriptions": ["旧文案"]}), + ), + ( + 'c', + serde_json::json!({"requestedPermissions": [], "permissionDescriptions": []}), + ), + ('d', serde_json::json!({})), + ] { + // 每个制品使用独立平台,保留同一版本的平台不可变约束。 + let fixture = artifact(digest, &[&format!("test-{digest}")]); + let installed = store + .install_artifact(fixture.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + if digest == 'a' { + store + .accept_artifact(&fixture.metadata.sha256, &context()) + .await + .unwrap(); + } + sqlx::query("update plugin_artifacts set metadata_json=metadata_json || $2 || '{\"futureMetadata\":true}'::jsonb where sha256=$1") + .bind(&fixture.metadata.sha256).bind(extra).execute(&database.pool).await.unwrap(); + sqlx::query("insert into plugin_instances(id,artifact_sha256,name,enabled,configuration_json,bindings_json,revision) values ($1,$2,'Example',false,'{}','[]',$3)") + .bind(uuid::Uuid::now_v7()).bind(&fixture.metadata.sha256) + .bind(i64::try_from(installed.config_revision.get()).unwrap()) + .execute(&database.pool).await.unwrap(); + } + let mut expected: Vec = + sqlx::query_scalar("select to_jsonb(a) from plugin_artifacts a order by sha256") + .fetch_all(&database.pool) + .await + .unwrap(); + for row in &mut expected { + let metadata = row["metadata_json"].as_object_mut().unwrap(); + metadata.remove("requestedPermissions"); + metadata.remove("permissionDescriptions"); + } + let instances: Vec = + sqlx::query_scalar("select to_jsonb(i) from plugin_instances i order by id") + .fetch_all(&database.pool) + .await + .unwrap(); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + for _ in 0..2 { + super::super::TEST_MIGRATOR + .run(&database.pool) + .await + .unwrap(); + let actual: Vec = + sqlx::query_scalar("select to_jsonb(a) from plugin_artifacts a order by sha256") + .fetch_all(&database.pool) + .await + .unwrap(); + assert_eq!( + actual, expected, + "只清理两个旧字段,保留包体、接受事实与其他元数据" + ); + let current_instances: Vec = + sqlx::query_scalar("select to_jsonb(i) from plugin_instances i order by id") + .fetch_all(&database.pool) + .await + .unwrap(); + assert_eq!(current_instances, instances); + let snapshot = store.load_instances().await.unwrap(); + assert_eq!(snapshot.instances.len(), 4); + assert_eq!( + snapshot.config_revision.get(), + u64::try_from(revision).unwrap() + ); + } + database.close().await; +} + #[tokio::test] async fn artifact_acceptance_is_exact_and_idempotent() { let Some(database) = TestDatabase::create("plugin_artifact_acceptance").await else { diff --git a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs index 8b35dd256..3e0d038d1 100644 --- a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs +++ b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs @@ -36,7 +36,7 @@ fn instance(digest: String) -> PluginInstance { trusted_process: true, configuration: serde_json::json!({"baseUrl":"https://example.com"}), secrets: BTreeMap::from([("token".into(), "sensitive-fixture".into())]), - grants: vec![], + bindings: vec![], revision: Revision::new(1).unwrap(), } @@ -149,6 +149,7 @@ fn usage_binding(client_key_id: &str, account_group_id: &str) -> PluginCapabilit account_group_ids: vec![account_group_id.into()], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![], } } @@ -166,6 +167,7 @@ fn frontend_authentication_binding( account_group_ids: vec![], provider_ids: vec![], models: vec![], + event: None, identity_bindings: vec![PluginFrontendIdentityBinding { principal: principal.into(), client_key_id: client_key_id.into(), @@ -315,8 +317,7 @@ async fn instance_save_rejects_unaccepted_enablement_and_forged_acceptance_facts }; initialize_revision(&database).await; let store = PgPluginStore::new(database.pool.clone()); - let mut package = artifact('d', &["linux-x86_64"]); - package.metadata.requested_permissions = vec!["network".into()]; + let package = artifact('d', &["linux-x86_64"]); let installed = store .install_artifact(package, PluginSource::Upload, &context()) .await @@ -350,16 +351,14 @@ async fn instance_save_rejects_unaccepted_enablement_and_forged_acceptance_facts .accept_artifact(&installed.artifact.metadata.sha256, &context()) .await .unwrap(); - let missing_grant = instance(installed.artifact.metadata.sha256); - assert_eq!( - store - .save_instance(missing_grant, accepted.config_revision, &context()) - .await - .err() - .expect("accepted artifact grants cannot be forged") - .kind(), - AdminStoreErrorKind::Invalid - ); + let mut enabled = instance(installed.artifact.metadata.sha256); + enabled.enabled = true; + let saved = store + .save_instance(enabled, accepted.config_revision, &context()) + .await + .expect("accepted artifact can be enabled without permission grants"); + assert!(saved.instance.enabled); + assert!(saved.instance.trusted_process); database.close().await; } diff --git a/backend/crates/gateway-store/tests/postgres/plugins/state.rs b/backend/crates/gateway-store/tests/postgres/plugins/state.rs index 7177605c2..1024e194c 100644 --- a/backend/crates/gateway-store/tests/postgres/plugins/state.rs +++ b/backend/crates/gateway-store/tests/postgres/plugins/state.rs @@ -62,7 +62,7 @@ fn instance(artifact_sha256: String) -> PluginInstance { trusted_process: true, configuration: json!({}), secrets: BTreeMap::new(), - grants: vec![], + bindings: vec![], revision: Revision::new(1).unwrap(), } diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index ca5342f30..a0ef8e37f 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -12,7 +12,6 @@ fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate { request_profile_updates: BTreeMap::new(), request_location_enabled: false, request_location: Default::default(), - admin_api_key: None, refresh_margin_seconds, refresh_concurrency: 2, max_concurrent_per_account: 3, @@ -802,3 +801,99 @@ async fn request_profile_projection_is_revision_consistent_and_includes_key_over assert_eq!(error.kind(), ProviderStoreErrorKind::Conflict); database.close().await; } + +#[tokio::test] +async fn control_plane_replacement_commits_one_writer_per_revision_and_preserves_newer_settings() { + use gateway_store::postgres::{ + AdminAuditActorKind, AdminAuditEvent, ControlPlaneReplacement, ControlPlaneRepository, + PgControlPlaneRepository, + }; + use gateway_store::{ConflictKind, StoreError}; + let Some(database) = TestDatabase::create("settings_compare_replace").await else { + return; + }; + let repository = PgControlPlaneRepository::new(database.pool.clone()); + let revision = repository + .load_control_plane() + .await + .unwrap() + .settings + .config_revision; + let replacement = |id: &str, margin| ControlPlaneReplacement { + expected_revision: revision, + settings: settings_with_margin(margin), + audit: AdminAuditEvent { + id: id.into(), + actor_kind: AdminAuditActorKind::System, + actor_admin_user_id: None, + actor_ref: "system".into(), + admin_request_id: Some(id.into()), + action: "settings.replace".into(), + entity_kind: "runtime_settings".into(), + entity_ref: "1".into(), + config_revision: None, + changed_fields: vec!["refresh_margin_seconds".into()], + created_at: Utc::now(), + }, + }; + let (first, second) = tokio::join!( + repository.replace_control_plane(replacement("first", 1800)), + repository.replace_control_plane(replacement("second", 7200)), + ); + let (saved, conflict) = match (first, second) { + (Ok(saved), Err(error)) | (Err(error), Ok(saved)) => (saved.settings, error), + other => panic!("expected exactly one successful transaction: {other:?}"), + }; + assert!(matches!( + conflict, + StoreError::Conflict { + kind: ConflictKind::StaleRevision, + .. + } + )); + let current = repository.load_control_plane().await.unwrap().settings; + assert_eq!(current.config_revision.get(), revision.get() + 1); + assert_eq!(current.refresh_margin_seconds, saved.refresh_margin_seconds); + let audit_count: i64 = sqlx::query_scalar( + "select count(*) from admin_audit_events where action = 'settings.replace'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audit_count, 1); + // API Key 更新也推进相同版本,旧设置快照不能复活已经替换的 Key。 + let mut key_audit = replacement("key", 3600).audit; + key_audit.action = "settings.admin_key".into(); + repository + .replace_admin_api_key(Some("new-test-key".into()), key_audit) + .await + .unwrap(); + let mut stale = replacement("stale", 3600); + stale.expected_revision = saved.config_revision; + assert!(matches!( + repository.replace_control_plane(stale).await, + Err(StoreError::Conflict { + kind: ConflictKind::StaleRevision, + .. + }) + )); + let mut fresh = replacement("fresh", 3600); + fresh.expected_revision = repository + .load_control_plane() + .await + .unwrap() + .settings + .config_revision; + repository.replace_control_plane(fresh).await.unwrap(); + assert_eq!( + repository + .load_control_plane() + .await + .unwrap() + .settings + .admin_api_key + .as_deref(), + Some("new-test-key") + ); + database.close().await; +} diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index eda5225e1..b41dc32f5 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -897,7 +897,6 @@ pub(super) fn map_client_error( )), CodexClientError::InvalidHeaderName(_) | CodexClientError::InvalidHeaderValue(_) - | CodexClientError::MiddlewareHeaderConflict | CodexClientError::WebSocketEncode(_) | CodexClientError::RequestBodyEncode(_) | CodexClientError::RequestCompression(_) @@ -1099,11 +1098,6 @@ fn client_diagnostic(error: &CodexClientError) -> Option { "invalid_header_value", "OpenAI request header value is invalid".to_owned(), ), - CodexClientError::MiddlewareHeaderConflict => ( - "prepare", - "middleware_header_conflict", - "OpenAI middleware header conflicts with a provider-managed header".to_owned(), - ), CodexClientError::WebSocketEncode(_) | CodexClientError::RequestBodyEncode(_) => ( "prepare", "request_encode_failed", diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 2c112b931..4d9a1282c 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -189,8 +189,6 @@ impl CodexProvider { let continuation_requested = generate.native_continuation_requested(); let mut upstream = encode_generate_request(generate, upstream_model.as_str(), None) .map_err(map_request_error)?; - // 插件加工后仍重新应用宿主强制策略与本地会话身份。 - upstream.apply_fast_policy(context.disable_fast()); if let Some(conversation_id) = previous_session .as_ref() .and_then(|state| state.conversation_id.as_ref()) @@ -467,6 +465,33 @@ impl Provider for CodexProvider { UpstreamSendState::NotSent, )); }; + // 请求设置先形成原生正文基线;attempt 的显式改写进入终端后不再次被覆盖。 + let mut operation = Operation::Generate(generate.clone()); + if context.disable_fast() + && let Operation::Generate(generate) = &operation + && generate.protocol_payload().protocol() == PROVIDER_NAME + { + let mut request = + CodexResponsesRequest::from_body(generate.protocol_payload().body().clone()); + request.apply_fast_policy(true); + let body = serde_json::to_vec(request.body()).map_err(|_| { + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + })?; + operation = operation + .replace_middleware_wire(PROVIDER_NAME, body.into()) + .map_err(|_| { + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + })?; + } + let Operation::Generate(generate) = &operation else { + unreachable!("generate settings keep the operation kind") + }; let Some(upstream_model) = candidate.upstream_model() else { return Err(provider_error( ProviderErrorKind::Protocol, diff --git a/backend/crates/providers/openai/src/provider/upstream_adapter.rs b/backend/crates/providers/openai/src/provider/upstream_adapter.rs index 92ca584a9..6f434fc72 100644 --- a/backend/crates/providers/openai/src/provider/upstream_adapter.rs +++ b/backend/crates/providers/openai/src/provider/upstream_adapter.rs @@ -23,7 +23,7 @@ impl CodexProvider { #[expect(clippy::too_many_arguments)] pub(super) fn execute_upstream_adapter( self: Arc, - mut operation: Operation, + operation: Operation, headers: Vec, model: UpstreamModelId, context: AttemptContext, @@ -38,29 +38,6 @@ impl CodexProvider { UpstreamSendState::NotSent, )); } - if context.disable_fast() - && let Operation::Generate(generate) = &operation - && generate.protocol_payload().protocol() == PROVIDER_NAME - { - // 适配器终端同样在 attempt 加工后应用原生强制策略,不另写一套档位规则。 - let mut request = - CodexResponsesRequest::from_body(generate.protocol_payload().body().clone()); - request.apply_fast_policy(true); - let body = serde_json::to_vec(request.body()).map_err(|_| { - provider_error( - ProviderErrorKind::InvalidRequest, - UpstreamSendState::NotSent, - ) - })?; - operation = operation - .replace_middleware_wire(PROVIDER_NAME, body.into()) - .map_err(|_| { - provider_error( - ProviderErrorKind::InvalidRequest, - UpstreamSendState::NotSent, - ) - })?; - } let requested_service_tier = match &operation { Operation::Generate(generate) if generate.protocol_payload().protocol() == PROVIDER_NAME => diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index af8d89a45..599db33e8 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -224,8 +224,6 @@ pub enum CodexClientError { #[error("invalid request header value: {0}")] InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), /// 中间件业务头试图覆盖 Provider 已构造的受管头。 - #[error("middleware request header conflicts with a provider-managed header")] - MiddlewareHeaderConflict, /// SSE 响应解析失败。 #[error("invalid upstream SSE response: {0}")] InvalidSse(#[from] SseError), @@ -298,9 +296,6 @@ impl fmt::Debug for CodexClientError { Self::InvalidHeaderValue(_) => { formatter.write_str("CodexClientError::InvalidHeaderValue([REDACTED])") } - Self::MiddlewareHeaderConflict => { - formatter.write_str("CodexClientError::MiddlewareHeaderConflict") - } Self::InvalidSse(_) => formatter.write_str("CodexClientError::InvalidSse([REDACTED])"), Self::ModelCatalog(error) => formatter .debug_tuple("CodexClientError::ModelCatalog") @@ -355,7 +350,6 @@ impl CodexClientError { | Self::CustomCa(_) | Self::InvalidHeaderName(_) | Self::InvalidHeaderValue(_) - | Self::MiddlewareHeaderConflict | Self::WebSocketEncode(_) | Self::RequestBodyEncode(_) | Self::RequestCompression(_) => None, diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index 4d4c4b1dd..bb8cfee10 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -217,11 +217,12 @@ impl CodexBackendClient { &self, headers: &mut HeaderMap, ) -> CodexClientResult<()> { - let provider_headers = headers.keys().cloned().collect::>(); + let mut replaced = std::collections::HashSet::new(); for header in &self.middleware_headers { let name = HeaderName::from_bytes(header.name().as_bytes())?; - if is_managed_identity_header(name.as_str()) || provider_headers.contains(&name) { - return Err(super::client::CodexClientError::MiddlewareHeaderConflict); + // 宿主画像和账号鉴权先形成基线;插件首次写入同名头时替换,随后保留多值。 + if replaced.insert(name.clone()) { + headers.remove(&name); } headers.append(name, HeaderValue::from_bytes(header.value())?); } diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 2c2b070de..595d773cc 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -1496,7 +1496,7 @@ fn initialized_provider_request(operation: Operation, account_id: &str) -> Provi let account_scope = initialized_account_scope(account_id); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - account_policy(), + gateway_core::settings::SettingsValues::new(2, 10, "smart", Default::default(), None, None), vec![provider.clone()], vec![ProviderModel::new( provider, diff --git a/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs b/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs index 3164301b2..eca74f771 100644 --- a/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs +++ b/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs @@ -196,43 +196,86 @@ async fn websocket_isolates_account_headers_and_only_scopes_guardian_parent_refe } #[tokio::test] -async fn middleware_cannot_reintroduce_account_headers_before_http_send_or_websocket_open() { - let store = Arc::new(MemoryAccountStore::default()); - create_account(&store, SELECTED_ACCOUNT).await; - let server = MockServer::start().await; - for operation in [http_generate_operation(), generate_operation()] { - for name in [ +async fn middleware_can_override_account_headers_on_http_and_websocket() { + for websocket in [false, true] { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, SELECTED_ACCOUNT).await; + let (base_url, capture): ( + String, + futures::future::BoxFuture<'static, reqwest::header::HeaderMap>, + ) = if websocket { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut headers = None; + let mut socket = + crate::transport::accept_codex_test_websocket_with(socket, |request, _| { + headers = Some(request.headers().clone()); + }) + .await; + socket.next().await.unwrap().unwrap(); + socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_plugin_headers","model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap(); + headers.unwrap() + }); + (base_url, Box::pin(async { task.await.unwrap() })) + } else { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + ( + server.uri(), + Box::pin( + async move { server.received_requests().await.unwrap()[0].headers.clone() }, + ), + ) + }; + let names = [ "x-openai-account-routing-override", "x-openai-fedramp", "x-codex-installation-id", - ] { - let result = provider_with_base_url(&store, server.uri()) - .execute( - planned_request("openai", operation.clone()), - context_with_middleware( - "req_managed_account_header", - Arc::new(RecordingMiddleware { - observed: Arc::default(), - replacement: ("service_tier".to_owned(), json!("default")), - request_headers: vec![MiddlewareHeader::new( - name, - Bytes::from_static(b"downstream-value"), - )], - }), - false, - ), - ) - .await; - let error = match result { - Err(error) => error, - Ok(mut stream) => match stream.next().await { - Some(Err(error)) => error, - _ => panic!("managed account header must fail before send: {name}"), - }, - }; - assert_eq!(error.kind(), ProviderErrorKind::Protocol); - assert_eq!(error.send_state(), UpstreamSendState::NotSent); + ]; + let mut stream = provider_with_base_url(&store, base_url) + .execute( + planned_request( + "openai", + if websocket { + generate_operation() + } else { + http_generate_operation() + }, + ), + context_with_middleware( + "req_plugin_account_header", + Arc::new(RecordingMiddleware { + observed: Arc::default(), + replacement: ("service_tier".into(), json!("default")), + request_headers: names + .iter() + .map(|name| { + MiddlewareHeader::new(*name, Bytes::from_static(b"plugin-value")) + }) + .collect(), + }), + false, + ), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let headers = capture.await; + for name in names { + assert_eq!(headers[name], "plugin-value"); } } - assert!(server.received_requests().await.unwrap().is_empty()); } diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index f1c319a54..24a92ed54 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -50,6 +50,7 @@ use gateway_core::routing::{ RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId, }; use gateway_core::runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}; +use gateway_core::settings::SettingsValues; use gateway_core::upstream::UpstreamSendState; use provider_openai::config::DEFAULT_STREAM_MAX_RETRIES; use provider_openai::credential::{ @@ -303,7 +304,7 @@ async fn native_openai_claims_translated_session_affinity_before_send() { } #[tokio::test] -async fn attempt_middleware_runs_once_before_native_encoding_and_fast_policy() { +async fn attempt_middleware_overrides_resolved_settings_and_headers() { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_provider_contract").await; let server = MockServer::start().await; @@ -314,14 +315,14 @@ async fn attempt_middleware_runs_once_before_native_encoding_and_fast_policy() { .insert_header("content-type", "text/event-stream") .set_body_string(CAPTURE_COMPLETED_SSE), ) - .expect(1) + .expect(2) .mount(&server) .await; let observed = Arc::new(Mutex::new(Vec::new())); let operation = Operation::Generate(GenerateRequest::from_protocol_payload( ProtocolPayload::json_object( "openai", - json!({"model":"gpt-5.6-sol", "input":"hello", "unknown":{"preserved":true}}) + json!({"model":"gpt-5.6-sol", "input":"hello", "service_tier":"priority", "unknown":{"preserved":true}}) .as_object() .unwrap() .clone(), @@ -338,6 +339,10 @@ async fn attempt_middleware_runs_once_before_native_encoding_and_fast_policy() { observed: Arc::clone(&observed), replacement: ("service_tier".to_owned(), json!("priority")), request_headers: vec![ + MiddlewareHeader::new( + "authorization", + Bytes::from_static(b"Bearer plugin-override"), + ), MiddlewareHeader::new( "x-business-context", Bytes::from_static(b"tenant-public"), @@ -361,21 +366,26 @@ async fn attempt_middleware_runs_once_before_native_encoding_and_fast_policy() { let observed = observed.lock().unwrap(); assert_eq!(observed.len(), 1); assert_eq!(observed[0]["input"], "hello"); + assert_eq!(observed[0]["service_tier"], "default"); } let requests = server.received_requests().await.unwrap(); let sent = captured_request_body(&requests[0]); - assert_eq!(sent["service_tier"], "default"); + assert_eq!(sent["service_tier"], "priority"); + assert_eq!( + captured_header_values(&requests[0], "authorization"), + vec![b"Bearer plugin-override".to_vec()] + ); assert_eq!(sent["unknown"]["preserved"], true); assert_eq!( captured_header_values(&requests[0], "x-business-context"), vec![b"tenant-public".to_vec(), b"trace-public".to_vec()], ); - let mut conflict = provider_with_base_url(&store, server.uri()) + let mut overridden = provider_with_base_url(&store, server.uri()) .execute( planned_request("openai", http_generate_operation()), context_with_middleware( - "req_native_header_conflict", + "req_native_header_override", Arc::new(RecordingMiddleware { observed: Arc::default(), replacement: ("service_tier".to_owned(), json!("priority")), @@ -389,12 +399,15 @@ async fn attempt_middleware_runs_once_before_native_encoding_and_fast_policy() { ) .await .expect("header validation remains on the cold Provider stream"); - let Some(Err(error)) = conflict.next().await else { - panic!("a Provider-managed header collision must fail before send") - }; - assert_eq!(error.kind(), ProviderErrorKind::Protocol); - assert_eq!(error.send_state(), UpstreamSendState::NotSent); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + while let Some(event) = overridden.next().await { + event.unwrap(); + } + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!( + captured_header_values(&requests[1], "x-codex-routing-hint"), + vec![b"plugin-value".to_vec()] + ); } #[tokio::test] @@ -1256,7 +1269,7 @@ fn planned_request_for_model( )); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - account_policy(), + gateway_core::settings::SettingsValues::new(2, 10, "smart", Default::default(), None, None), vec![provider.clone()], vec![ProviderModel::new( provider, @@ -1290,7 +1303,7 @@ fn planned_provider_endpoint_request(provider_name: &str, operation: Operation) )); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - account_policy(), + gateway_core::settings::SettingsValues::new(2, 10, "smart", Default::default(), None, None), vec![provider.clone()], Vec::new(), Vec::new(), @@ -1392,7 +1405,7 @@ impl MiddlewarePlan for TranslationMiddleware { &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Attempt); assert!(context.account_id().is_some()); @@ -1414,7 +1427,7 @@ impl MiddlewarePlan for PassThroughMiddleware { &self, _: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { next.run(request) } @@ -1432,7 +1445,7 @@ impl MiddlewarePlan for RecordingMiddleware { &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Attempt); assert_eq!(request.protocol(), "openai"); @@ -12059,8 +12072,8 @@ async fn public_catalog_filters_each_api_account_before_union_without_gating_inf use gateway_core::account::{AccountModelAccess, AccountModelAccessMode}; use gateway_core::engine::provider::{Provider, ProviderRegistry}; use gateway_core::routing::snapshot::{ - RuntimeSnapshotCompiler, SnapshotFacts, SnapshotProviderAccountFacts, - SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort, + RuntimeSnapshotCompiler, SnapshotFacts, SnapshotProviderAccountFacts, SnapshotStoreError, + SnapshotStorePort, }; struct CatalogStore(SnapshotFacts); @@ -12131,7 +12144,7 @@ async fn public_catalog_filters_each_api_account_before_union_without_gating_inf let facts = SnapshotFacts::new( revision, revision, - SnapshotSettingsFacts::new( + SettingsValues::new( 3, 0, "smart", diff --git a/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs b/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs index 8144954ee..d6cef5deb 100644 --- a/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs +++ b/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs @@ -67,7 +67,7 @@ impl gateway_core::engine::upstream_adapter::UpstreamAdapter for AdapterProbe { }; assert_eq!( generate.protocol_payload().body()["service_tier"], - "default" + "priority" ); assert!( invocation diff --git a/backend/crates/providers/xai/src/provider/stream.rs b/backend/crates/providers/xai/src/provider/stream.rs index a4062ed97..a5d2b2263 100644 --- a/backend/crates/providers/xai/src/provider/stream.rs +++ b/backend/crates/providers/xai/src/provider/stream.rs @@ -37,22 +37,17 @@ fn append_middleware_grok_headers( target: &mut Vec, headers: &[MiddlewareHeader], ) -> Result<(), ProviderError> { - let provider_header_count = target.len(); + let mut replaced = std::collections::HashSet::new(); for header in headers { - if target[..provider_header_count] - .iter() - .any(|existing| existing.name().eq_ignore_ascii_case(header.name())) - { - return Err(provider_error( - ProviderErrorKind::Protocol, - UpstreamSendState::NotSent, - )); + if replaced.insert(header.name().to_ascii_lowercase()) { + target.retain(|existing| !existing.name().eq_ignore_ascii_case(header.name())); } let value = std::str::from_utf8(header.value()) .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; - target.push(crate::transport::GrokHeader::public( + // 插件可写任意字段,值不进入 Debug;transport 仍读取完整原值。 + target.push(crate::transport::GrokHeader::sensitive( header.name().to_owned(), - value.to_owned(), + crate::SecretValue::new(value), )); } Ok(()) diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 9f55dbb00..81d0b8557 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -401,27 +401,35 @@ async fn attempt_middleware_runs_once_before_native_encoding() { ); } - let conflict_transport = StubInferenceTransport::success(); - let conflict_provider = provider(StubSelector::success(), conflict_transport.clone()).await; - let mut conflict = conflict_provider + let overridden_transport = StubInferenceTransport::success(); + let overridden_provider = provider(StubSelector::success(), overridden_transport.clone()).await; + let mut overridden = overridden_provider .execute( provider_request("xai"), context_with_middleware(Arc::new(RecordingMiddleware { observed: Arc::default(), replacement: None, request_headers: vec![MiddlewareHeader::new( - "x-grok-client-version", - Bytes::from_static(b"plugin-value"), + "Authorization", + Bytes::from_static(b"Bearer plugin-value"), )], })), ) .await .expect("header validation remains on the cold Provider stream"); - let error = next_provider_error(&mut conflict).await; - assert_eq!(error.kind(), ProviderErrorKind::Protocol); - assert_eq!(error.send_state(), UpstreamSendState::NotSent); - assert_eq!(conflict_transport.calls.load(Ordering::SeqCst), 0); - assert!(conflict_transport.requests.lock().unwrap().is_empty()); + while let Some(event) = overridden.next().await { + event.unwrap(); + } + assert_eq!(overridden_transport.calls.load(Ordering::SeqCst), 1); + let requests = overridden_transport.requests.lock().unwrap(); + let values = requests[0] + .headers() + .iter() + .filter(|header| header.name().eq_ignore_ascii_case("authorization")) + .map(|header| header.value().expose()) + .collect::>(); + assert_eq!(values, ["Bearer plugin-value"]); + assert!(!format!("{:?}", requests[0].headers()).contains("Bearer plugin-value")); } #[tokio::test] @@ -1483,7 +1491,7 @@ fn provider_request_with_model_capabilities( )); let snapshot = RuntimeSnapshot::new( ConfigRevision::new(1).expect("revision"), - selection_policy(), + gateway_core::settings::SettingsValues::new(2, 0, "smart", Default::default(), None, None), vec![provider], vec![provider_model], vec![], @@ -1519,7 +1527,7 @@ impl MiddlewarePlan for TranslationMiddleware { &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Attempt); assert!(context.account_id().is_some()); @@ -1541,7 +1549,7 @@ impl MiddlewarePlan for PassThroughMiddleware { &self, _: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { next.run(request) } @@ -1561,7 +1569,7 @@ impl MiddlewarePlan for RecordingMiddleware { &self, context: MiddlewareContext, request: MiddlewareRequest, - next: Box, + next: MiddlewareNext, ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Attempt); let (protocol, mut headers, bytes) = request.into_parts(); diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 9b6f26804..bc5d8bb7f 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -17,3 +17,4 @@ a1e23b8535ed41de27861833cd2d5ee7498a9bb0e523156ba3f011f9c6b3966b 0016_client_pr fcfae912ddefd54551ebf8e7e733442519d2862d2977134d548ebc1e75c1dc34 0017_unlimited_default_account_concurrency.sql 229c70296764b94fe69c3f19889e566fa290c6c44c64970efe96d7fd2a0008d5 0018_plugins.sql 40a4d4b16e6ec2c2c659980d416b089fc170acd2255aaaf751c4a04c3790a941 0019_plugin_resources.sql +e8c1339166ac02fb2d4a077424ee5f64f6dc20eb4d3f8ec4fcb09c0f2a0e61f6 0020_plugin_trust.sql diff --git a/backend/migrations/0020_plugin_trust.sql b/backend/migrations/0020_plugin_trust.sql new file mode 100644 index 000000000..c127b74d0 --- /dev/null +++ b/backend/migrations/0020_plugin_trust.sql @@ -0,0 +1,4 @@ +-- 插件清单只声明处理器与配置;制品接受记录保留用户的完整信任决定。 +update plugin_artifacts +set metadata_json = metadata_json - array['requestedPermissions', 'permissionDescriptions'] +where metadata_json ?| array['requestedPermissions', 'permissionDescriptions']; diff --git a/deploy/README.md b/deploy/README.md index 2f63a4ad4..12178a77b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -169,11 +169,11 @@ duration 使用 `500ms`、`1m30s` 等带单位形式。参数可能进入 shell 了敏感参数就认为命令行是安全的密钥输入通道;真实凭据优先使用对应插件提供的受控登录或导入入口 成功命令原样返回插件退出码与输出。命令受总期限约束,`Ctrl-C` 或终止信号会取消调用并回收插件进程。 -若命令产生账号,只有成功结果才按顺序通过 Admin 保存;调用受制品访问域、`command_line` 阶段和当前资源事实约束, +若命令产生账号,只有成功结果才按顺序通过 Admin 保存;调用仍检查期限、实例版本和当前资源事实, 模型执行 Key 由具体调用选择,不在实例中预绑定。 部分保存后失败或取消不会自动重放,先核对账号和审计记录,再决定是否重新执行 -独立仓库 `codex-proxy-plugins` 的 `examples/request-workbench` 提供不访问真实上游的综合示例 +独立仓库 `codex-proxy-plugins` 的 `examples/workbench` 提供命令行、请求处理与管理页面示例;模型相关操作会产生真实上游用量 ## 公网访问 @@ -418,23 +418,13 @@ OAuth 恢复开关为 `host.logging.oauth_recovery`,默认关闭,与普通 请求问题反馈使用 [接口问题反馈表单](../.github/ISSUE_TEMPLATE/api-bug-report.yml);错误诊断与查询合同见 [API 文档](../docs/api.md#10-dashboard用量与错误) -### 插件取文与 Fake-IP DNS +### 插件取文与 DNS -插件受管 HTTP 会检查域名解析出的全部地址,再使用通过校验的地址连接,默认拒绝内网、回环、 -链路本地和保留地址。配置了代理也不会跳过目标地址校验,宿主不自动改用公共 DNS +插件受管 HTTP 由实际运行网关的主机或容器解析 DNS,再通过直连或账号代理访问目标。插件拥有完整网络访问能力,宿主不按插件身份限制私网、回环或保留地址 -若取文提示“目标地址被安全策略拦截”,先在**实际运行网关的主机或容器内**检查域名解析。 -Clash / Mihomo 的 Fake-IP 模式可能把公网域名解析为 `198.18.0.0/15` 中的地址,此时应调整代理 DNS, -让需要访问的域名返回真实公网地址,不要关闭宿主检查或放行整个保留地址段 +域名解析失败时先核对网关运行环境的 DNS。Clash / Mihomo 的 Fake-IP 地址需要相应代理接管;无法连通时可让目标域名返回真实地址,并检查所选代理。代理失败不会自动回退直连 -例如 Mihomo 使用 `fake-ip-filter-mode: blacklist` 时,将 `github.com` 追加到现有 -`dns.fake-ip-filter` 列表,保留原条目及其他 DNS、代理规则。Clash Verge Rev 开启 DNS 覆写时, -应在 DNS 设置中修改持久配置,而不是只编辑会被重新生成的运行配置。规则模式和白名单模式含义不同, -按所用版本的 [Mihomo DNS 文档](https://wiki.metacubex.one/config/dns/)配置。 -重新加载后确认解析结果已改变,再验证实际取文;浏览器或普通 `curl` 可访问不能代替宿主校验 - -宿主日志中的“插件受管 HTTP 请求失败”只记录固定原因、错误类别和发送状态,不包含请求网址、 -请求头、正文或凭据。域名解析失败、网络超时、连接失败和地址拒绝应分别处理,不需要开启请求转储 +排障区分域名解析失败、超时、连接失败和响应读取失败,避免在日志或截图中输出真实请求头、正文及凭据 ## 密码语义 @@ -584,9 +574,13 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs___.ta | 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 | - 更新或回滚前,逐一检查启用实例的精确包与目标宿主是否兼容。制品缺失、不兼容或并发配置变更会阻止切换;失败或取消时恢复整组文件,不自动停用插件或接受其他制品 -- 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不接受访问域、不创建配置、不启用,也不删除旧包。管理员确认访问域后才执行默认配置流程;重复摘要保留首次安装出处 +- 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不确认信任、不创建配置、不启用,也不删除旧包。管理员确认信任后才执行默认配置流程;重复摘要保留首次安装出处 - `sealed` 是构建封口标记,不是密码学签名。普通上传、URL 或 GitHub 安装不能获得 `builtin` 身份,独立教学示例也不例外 +已安装制品的元数据读取允许未知字段,可选字段缺失时使用已定义的默认值;无需仅为这些字段差异重新安装。 +废弃字段由启动迁移统一清理,制品摘要、包体、接受事实与实例配置继续保留。缺失必需身份、数据类型错误、 +清单或协议不兼容不会因此被忽略,仍需按具体错误处理;不要手动删除元数据字段或修改迁移 checksum 来绕过检查 + 使用不支持 `plugins/official` 目录的更新器时,须完整解压平台发行包或更新正式容器镜像,确保发行文件齐全。 macOS arm64 提供构建产物;部署前需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用 @@ -615,7 +609,7 @@ OpenAI 主动额度重置卡及消费结果由上游持有,不属于本地备 来源使用的出站代理及其认证也须随库恢复,不能只备份 `plugin_*` 表;来源代理与账号代理独立选择, 不依赖进程代理环境,故障时不会自动回退直连。 包体单个上限为 32 MiB,需要计入数据库及备份容量;解压运行目录和 Redis 协调状态是可重建数据, - 不作为插件安装或访问域事实。恢复时保留制品接受状态、实例配置、账号引用和状态代次的一致性;插件降级仍须 + 不作为插件安装或制品接受事实。恢复时保留制品接受状态、实例配置、账号引用和状态代次的一致性;插件降级仍须 通过状态兼容校验,不通过手工替换缓存目录回滚 - 官方运行镜像内置与 Compose 数据库服务版本一致的 `pg_dump` / `pg_restore`,无需额外安装。 自行替换 PostgreSQL 版本时,应同步核对备份工具版本 diff --git a/docs/api.md b/docs/api.md index 5e20902c6..94b294353 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,10 @@ ## 1. 鉴权与公共约定 +下文描述宿主路由的默认行为。已启用的 `http` 中间件在路由匹配、认证和正文解析前统一进入, +可改写请求、覆盖本次请求设置、直接返回响应或接管 WebSocket 升级;管理、模型、健康检查和静态资源使用同一入口。 +挂载与覆盖合同见 [SDK 洋葱中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) + ### OpenAI 数据面客户端接口 默认情况下,`/v1/*` 使用管理端创建的 Client Key: @@ -264,6 +268,10 @@ Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品 #### WebSocket 与上游传输 +公开 HTTP 请求在路由匹配前经过已绑定的 `http` 中间件;插件可以改写握手路径和请求参数。 +已升级的 Responses 连接在入站解析前和出站写入前经过 `websocket` 中间件,完整消息与主动发送接口见 +[SDK 洋葱中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件)。下述规则描述默认协议处理 + Responses WebSocket 接受文本 `response.create` 和 `response.interrupt`,创建请求在同一连接串行执行。当前响应期间收到的后续业务帧 留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。 接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。 @@ -1036,7 +1044,7 @@ Client Key 绑定的任一分组开启此限制(包括已禁用分组)时, 未绑定分组的 Key 不限制 Fast,不按最终所选账号的分组判断 关闭 Fast 只将顶层 `service_tier` 的 `priority`(含 `fast` 别名)改为显式 `default`,继续处理请求; -不改变 `flex`、`ultrafast`、缺失值、默认档、嵌套字段或其他 Provider。 +不改变 `flex`、`ultrafast`、缺失值、默认档、嵌套字段或其他 Provider。宿主设置先形成 attempt 输入基线;已安装插件显式改写档位时,以改写后的实际发送值为准。 HTTP 和每个 WebSocket `response.create` 均使用请求开始时的分组策略,同一请求重试保持该策略; HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造;复用 WS 时不重发握手头, 每个 `response.create` 仍独立应用档位策略,请求档位统计与本地费用估算使用各帧的最终出站档位 @@ -1162,9 +1170,14 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key | | `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key | +读取设置返回 `configRevision`。更新请求必须携带读取时的同一版本;版本比较、设置替换和审计在同一事务内完成。 +并发修改导致版本过期时返回 `409`,不保存当前请求;读取最新设置并确认差异后再提交,不能静默重试覆盖。 +成功响应返回新版本,后续请求使用发布后的快照 + 设置更新字段包括: ```text +configRevision providerRequestProfiles openaiClientProfile xaiClientProfile @@ -1665,7 +1678,7 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` 下载并解包后,Admin 会以目标发行清单声明的宿主插件合同检查每个启用实例的精确制品;回滚则对备份发行清单 执行同一检查。两条路径都会在文件交换前后复核同一全局插件配置版本。任一实例不兼容、制品缺失、请求取消或 配置并发变化都会失败;已交换的二进制、Web 资源和官方插件目录会成组恢复。预检不会自动停用实例、切换插件 -版本或增加权限 +版本或接受其他制品 状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。 `needRestart=true` 表示已验证的安装文件尚未在当前进程生效,此时应调用重启接口,不能重复发起更新或切换通道。 @@ -1679,7 +1692,7 @@ Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行 ## 12. 插件管理 除明确标注的公开入口外,以下接口需要管理身份。图标读取及 12.2 中标注的原始响应接口直接返回资源、插件响应 -或模型响应,其余接口使用统一管理响应信封。制品按摘要接受其不可变访问域;首次安装某个插件时会尝试创建默认配置, +或模型响应,其余接口使用统一管理响应信封。制品按摘要记录用户的完整信任决定;首次安装某个插件时会尝试创建默认配置, 配置完整则启用,缺少必填项则保留为待配置。操作流程见 [插件使用](plugins.md),开发合同见 [SDK](../backend/crates/gateway-plugin/sdk/README.md)。不提供官方商店或第三方市场订阅接口 @@ -1689,11 +1702,11 @@ Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行 | --- | --- | --- | | GET | `/api/admin/plugins/artifacts` | 列出制品的 `metadata`、固定 `source`、`installedAt` 与可为 `null` 的 `acceptedAt` | | GET | `/api/admin/plugins/artifacts/{sha256}/icon?theme=light\|dark` | 管理身份;按不可变制品摘要读取已校验图标;无图标返回 404 | -| POST | `/api/admin/plugins/artifacts/upload` | 上传原始 tar.gz 包体,最多 32 MiB;查询参数 `sha256` 必须固定已解析的包;接受访问域并完成安装 | +| POST | `/api/admin/plugins/artifacts/upload` | 上传原始 tar.gz 包体,最多 32 MiB;查询参数 `sha256` 必须固定已解析的包;确认信任并完成安装 | | POST | `/api/admin/plugins/artifacts/upload/verify` | 同样接收原始包体,只读解析并校验,返回 `metadata` 与 `source`,不安装 | -| POST | `/api/admin/plugins/artifacts/install` | 从 URL 或固定 GitHub Release 下载、校验,接受访问域并完成安装 | +| POST | `/api/admin/plugins/artifacts/install` | 从 URL 或固定 GitHub Release 下载、校验,确认信任并完成安装 | | POST | `/api/admin/plugins/artifacts/verify` | 按远程来源只读解析并校验完整包,返回 `metadata` 与固定 `source`,不持久化或启动插件 | -| POST | `/api/admin/plugins/artifacts/accept` | 严格请求 `{ "sha256": "<小写 SHA-256>" }`,接受已导入制品的访问域并完成安装 | +| POST | `/api/admin/plugins/artifacts/accept` | 严格请求 `{ "sha256": "<小写 SHA-256>" }`,确认信任已导入制品并完成安装 | | POST | `/api/admin/plugins/artifacts/delete` | 请求 `{ "sha256": "<小写 SHA-256>" }`,删除未被引用的制品 | | POST | `/api/admin/plugins/releases/query` | 查询指定 GitHub 仓库的最新稳定版或明确 tag | | POST | `/api/admin/plugins/updates/check` | 按插件已保存的来源和策略查询 Release,只读,不下载或切换实例 | @@ -1711,9 +1724,8 @@ Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行 插件 ID 由 `publisher + "." + name` 派生;`displayName` 和可选 `author` 仅用于展示 制品 `metadata` 返回 `pluginId`、`name`、`displayName`、`publisher`、可为 `null` 的 `author`、`version`、 -`description`、`license`、`sha256`、`platforms`、可为 `null` 的 `icon`、`contributes`、`requestedPermissions`、 -`permissionDescriptions`、`configurationSchema`、`secretFields` 和 `stateNamespaces`。`permissionDescriptions` 的每项为 -`{ permission, label, description }`,供安装确认直接展示。`contributes` 是以稳定 snake_case capability 为 key 的对象;每个值包含 +`description`、`license`、`sha256`、`platforms`、可为 `null` 的 `icon`、`contributes`、 +`configurationSchema`、`secretFields` 和 `stateNamespaces`。`contributes` 是以稳定 snake_case capability 为 key 的对象;每个值包含 `id`、`version`、`stages`、`inputFormats` 和 `outputFormats`。`id` 是实例绑定引用的完整贡献项 ID,客户端必须通过 当前制品的 `contributes` 映射确定其 capability,不能解析 ID 文本推测能力 @@ -1735,7 +1747,7 @@ Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行 #### 解析与确认安装 本地包先调用 `artifacts/upload/verify`,远程包先调用 `artifacts/verify`。校验只返回包信息与来源, -不保存、不接受访问域、不启动插件,也不代表实例配置或私有状态已经兼容。 +不保存、不确认信任、不启动插件,也不代表实例配置或私有状态已经兼容。 修改文件、地址、Release、摘要、凭据或代理选择后必须重新校验;确认安装时固定校验返回的身份、版本与摘要 远程解析请求包含 `location`、可选 `credentialIds` 和 `outboundProxyId`,不需要预先提供 ID 或版本。 @@ -1779,13 +1791,12 @@ URL 与 GitHub 的 `location.sha256` 在解析时均可省略,由服务端计 ``` 接受制品时,若该插件没有任何实例,服务端使用稳定的 creation ID 创建一份默认实例:普通配置取 schema 默认值, -敏感字段不复制默认值;除 `management`、`command_line` 和 `frontend_authentication` 外,为声明的贡献项及阶段建立 -空范围默认 binding。仅缺少必填项时 `configurationRequired=true` 且实例保持停用;配置完整时实例直接启用。 +敏感字段不复制默认值;按下文[能力绑定规则](#能力绑定与范围)为适用贡献项及阶段建立空范围默认 binding。 +仅缺少必填项时 `configurationRequired=true` 且实例保持停用;配置完整时实例直接启用。 非法 schema、类型错误或把敏感字段放入普通配置会拒绝安装。已有该插件实例时不额外创建或切换实例, `defaultInstanceId=null`。相同摘要的接受和默认实例创建可重试,不产生副本 -宿主发行目录导入的制品只写入不可变包和来源,`acceptedAt=null`;在严格复核 -`permissionDescriptions` 后调用 `artifacts/accept`。未接受制品不能用于创建、更新或启用实例 +宿主发行目录导入的制品只写入不可变包和来源,`acceptedAt=null`;确认来源与版本后调用 `artifacts/accept`。未接受制品不能用于创建、更新或启用实例 GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`tag`、`asset`、 `allow_prerelease` 和 `sha256`。解析时优先使用显式摘要或 GitHub 的 SHA-256,缺失时读取同一 Release 的 @@ -1842,7 +1853,7 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta 创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`, `secrets` 可选。输入严格拒绝未知字段;`trustedProcess` 和 `grants` 不是输入字段。`artifactSha256` 必须指向 -已接受的制品,其访问域由制品声明精确派生,实例不能增加或删减。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须 +已接受的制品。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须 放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值 创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。 @@ -1870,50 +1881,43 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta 最后一项表示使用了目标版本的配置快照。没有快照时只补充缺失的 schema 默认值,保留当前显式值;功能范围按能力与阶段映射到目标声明, 新能力使用默认绑定,已关闭的既有能力继续关闭,客户端认证仍须显式身份映射。草稿不代表已通过校验 -`switch-version` 重新生成草稿并执行准备与提交,名称和启停状态保持不变,权限从目标制品派生。不兼容设置返回 400,当前设置不变; +`switch-version` 重新生成草稿并执行准备与提交,名称和启停状态保持不变。不兼容设置返回 400,当前设置不变; 可读取草稿并通过 `instances/update` 携带原 `expectedRevision` 提交修正。不会推断字段改名或丢弃未知参数与密钥 每个实例、制品摘要保存一份最近启用时提交的配置快照,包含普通配置、secret 与 binding,停用草稿不覆盖快照。 `rollback` 只接受有快照的较早语义版本,恢复该版本设置并重新检查平台、配置与私有状态,保留当前名称与启停状态。 快照与实例修改共享事务,不从审计日志重建,也不恢复私有业务数据;删除实例或制品时删除对应快照 -#### 访问域与受管资源 +#### 完整信任与受管资源 -清单的 `requestedPermissions` 接受 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`,确认结果以制品摘要为边界;实例输入没有单独授权字段 +安装并启用插件意味着完整信任其代码与行为,安全由安装者承担。清单与握手不包含权限声明,宿主回调不按访问域或阶段授权,参见[插件信任说明](plugins.md#完整信任) -| 标识 | 含义 | -| --- | --- | -| `network` | 访问网络 | -| `models` | 查询模型和 Client Key 基本信息并调用模型,可能产生消耗 | -| `accounts` | 读取和修改账号,包括访问原始凭据 | -| `data` | 仅在管理或命令入口只读全部账号的基础信息和已有额度观测,不含凭据或预测 | -| `requests` | 查看和处理请求、响应、路由及账号选择 | -| `public_endpoints` | 提供无需登录即可访问的资源与回调入口 | +回调保持类型、期限、取消、实例 revision、资源归属和事务一致性检查。模型调用可显式选择 Key,按所选 Key 的准入、预算、用量与计费合同执行;省略 Key 时继承现有模型父请求身份 -访问域不是 operation、Provider、账号、origin 或命名空间白名单。Runtime 仍按当前贡献项、调用阶段、父调用和宿主业务 -端口限制可用方法,不能把一个域借给无关调用。账号资源以每次请求的 `accountId` 查找权威 Provider,不接受插件提供的 -Provider 事实;Client Key 列表只投影 `id`、`name`、`enabled`,不返回前缀、明文、账号绑定或额度策略。模型调用须显式 -选择 Key ID,并继续经过该 Key 的当前准入、范围、预算、用量与计费合同。私有状态只能访问清单声明的命名空间 - -受管 HTTP 还会执行宿主网络策略。目标默认只允许常规公网地址;每次请求重新校验全部解析地址,连接使用通过校验的 -固定地址并保留原始 Host 与 TLS 域名校验。托管回调由宿主解析目标域名,HTTP/HTTPS/SOCKS 代理接收固定 IP; -无法由宿主安全解析的目标会被拒绝。代理沿用所选账号配置,不自动回退直连;重定向不会自动跟随,后续地址必须重新校验 +受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;代理沿用账号配置,失败不自动回退直连,重定向不自动跟随 #### 能力绑定与范围 `bindings` 通过 `contribution` 指定清单已声明的贡献项,并包含该贡献项声明支持的 `stage`、顺序和失败策略; 尚未声明、尚未注册或阶段不匹配的贡献项不能启用。 -首次安装生成的默认实例会为适用贡献项的每个声明阶段创建 binding,`order=0`,观察阶段使用 `observe`, -其他阶段使用 `reject`,所有范围数组为空。`management`、`command_line` 按清单声明注册,不接受 binding; +首次安装生成的默认实例会为适用贡献项的每个声明阶段创建 binding;`observer` 分别创建完成与 WebSocket 两类事件绑定。 +默认 `order=0`、所有范围数组为空;观察阶段使用 `observe`,重试阶段使用 `delegate`,其他阶段使用 `reject`。 +`model_catalog`、`management`、`command_line` 和 `maintenance` 按清单声明注册,不接受 binding; `frontend_authentication` 必须由管理员显式配置身份映射,也不自动创建 -请求终态观察的绑定格式如下;同一实例同时订阅 `request_lifecycle` 和 `usage` 时使用相同 `order`, -宿主合并为一次调用,仅附带命中订阅的事实 +`middleware` v3 可绑定 `http`、`websocket`、`service`、`request` 和 `attempt`,按声明的挂载阶段分别配置。 +`http`、`websocket` 和 `service` 不使用 Key、分组、Provider 或模型范围,这些数组必须为空;插件在处理器内匹配路径、消息或服务操作。 +`request` 与 `routing` 支持 Key、分组和公开模型范围,Provider 范围必须为空;`attempt`、`upstream`、`scheduling`、`retry` 与 `observation` 可使用 Provider 范围 + +`observer` 固定使用 `observation/observe`,必填 `event` 为 `request_completed` 或 `websocket_response`。 +每种事件最多一条绑定,可分别设置请求范围;同一实例两类事件的 `order` 必须相同。其他能力不接受非空 `event`。 +完成事件完整提供终态、用量、费用与耗时,订阅不裁剪字段 ```json { - "contribution": "acme.usage-observer.usage", + "contribution": "acme.request-observer.observer", "stage": "observation", + "event": "request_completed", "order": 10, "failurePolicy": "observe", "clientKeyIds": [], @@ -1935,11 +1939,11 @@ Provider 范围匹配最终实际尝试的 Provider;未进入 Provider 的拒 普通 binding 不能携带 `identityBindings`。已停用、删除或不再授权的 Key 不会因映射恢复访问 实例不为管理页面或命令行预绑定模型执行 Key。管理页面经 12.2 的模型桥在每次调用时显式提交 `clientKeyId`; -CLI 插件也须在具体调用中选择当前 Key。管理员身份本身不提升为推理身份,Key 明文不会发送给插件 +CLI 插件也须在具体调用中选择当前 Key。管理员身份本身不提升为推理身份;模型桥用 Key ID 选择身份,不在桥参数中传递 Key 明文 -观察失败只记录诊断,不改变响应、路由、标准 Usage 或账单。当前仅派发一次终态,过载直接丢弃, -不承诺进程崩溃后的持久投递;这不是请求前拦截、流逐帧观察或账单重算接口。 -尚未通过重新鉴权、未建立冻结请求上下文的失败不触发当前终态观察 +观察失败只记录诊断,不改变响应、路由、标准 Usage 或账单。完成事件最多派发一次;上游 WebSocket 事件按请求保序, +原始帧通过独立载荷传递,完成通知不等待消息队列排空。过载可能丢弃事件,不承诺进程崩溃后的持久投递。 +尚未通过重新鉴权、未建立冻结请求上下文的失败不触发完成观察;下游消息改写使用 `websocket` 中间件,普通响应流改写使用请求中间件 #### 发布状态 @@ -1968,35 +1972,36 @@ CLI 插件也须在具体调用中选择当前 Key。管理员身份本身不提 | GET | `/api/admin/plugins/extensions/{target}/resources/{path}` | 管理身份;已校验的静态字节 | | POST | `/api/admin/plugins/extensions/{target}/models/responses?clientKeyId=` | 管理身份;原生 Responses JSON 或 SSE,不套管理信封 | | POST | `/api/admin/plugins/extensions/{target}/callback-tickets` | 管理身份;统一信封中的一次性回调票据 | -| GET | `/plugins/resources/{target}/{path}` | 无须登录,但资源须声明公开且制品已接受 `public_endpoints` 访问域 | +| GET | `/plugins/resources/{target}/{path}` | 无须登录,但资源须声明公开且制品已被接受 | | GET | `/plugins/callbacks/{target}/{path}` | 无须登录,但必须携带有效且未消费的 `state` 票据 | 实例目标失效返回 409,调用方应刷新扩展目录,不重放原操作。尚无可用发布视图时返回 503。 -所有入口都要求当前实例仍启用、制品已接受且版本匹配;公开资源与回调还要求制品声明 `public_endpoints`。 +所有入口都要求当前实例仍启用、制品已接受且版本匹配;公开资源与回调按插件注册内容开放。 公开入口不是读取任意包文件的路径。 单项静态资源最多 1 MiB,总量最多 8 MiB;资源必须同时出现在制品清单和注册结果中 -管理 API 只转交方法、相对路径、查询字符串、Content-Type、宿主请求关联与原始正文,不转交管理 Cookie -或任意认证头。仅接受注册的方法、路径和内容类型;查询最多 8 KiB,正文最多 1 MiB 且受运行帧预算约束, -GET/HEAD 不接受正文。响应状态码为 200–599,内容类型须在注册列表内,204/304 不允许正文;插件不能 -自选 Set-Cookie、Location 或其他响应头。失败可能发生在副作用之后,不提供自动重试 +管理 API 转交方法、相对路径、查询字符串、完整请求头、宿主请求关联与原始正文,保留 Cookie、认证头、 +重复头与非 UTF-8 值。仅接受注册的方法、路径和内容类型;查询最多 8 KiB,正文最多 1 MiB 且受运行帧预算约束, +GET/HEAD 不接受正文。响应状态码为 200–599,内容类型元数据须在注册列表内,204/304 不允许正文; +插件返回的完整响应头在 HTTP 语法校验后覆盖默认值。失败可能发生在副作用之后,不提供自动重试 动态 API 与登录回调设置 `Cache-Control: no-store`。静态资源成功响应设置 `private, no-cache` 与内容 ETag, 浏览器可保存正文,但每次复用前必须重新校验管理员身份(公开资源除外)、实例版本和资源授权; `If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。 -两类响应均设置 nosniff、no-referrer 与限制性 CSP。管理端页面使用隔离 iframe, +两类响应默认设置 nosniff、no-referrer 与限制性 CSP,动态处理器可显式覆盖默认响应头。管理端页面使用隔离 iframe, 仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。 页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见 [SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) 模型桥只接受严格的 `clientKeyId` 查询参数和 Responses JSON 正文;传输正文与解压后的正文均最多 8 MiB。 -服务端先复核固定目标与 `models` 访问域,再按 ID 查询当前 Client Key,并使用普通 Responses HTTP/SSE 执行链; +服务端先复核固定目标与实例 revision,再按 ID 查询当前 Client Key,并使用普通 Responses HTTP/SSE 执行链; 因此仍执行当前 Key 的准入、账号范围、预算、用量、计费与请求插件链,包括发起页面所属插件自己的适用 hook。 这不是嵌套的宿主模型回调,管理身份本身也不提供推理身份,Key 明文不会离开宿主 -等待首帧、非流式生成和流式正文交付期间约每秒复核一次固定目标与 `models` 访问域。实例停用、制品或 revision +等待首帧、非流式生成和流式正文交付期间约每秒复核一次固定目标与实例 revision。实例停用、制品或 revision 变化以及访问资格失效会取消底层执行并停止响应。成功或协议错误沿用 Responses 内容类型,并返回可检索的 -`x-request-id` 与 `x-gateway-request-id`;响应移除 Cookie 和认证头并设置 nosniff、no-referrer 与限制性 CSP。 +`x-request-id` 与 `x-gateway-request-id`。模型插件读取完整请求头,响应头不按插件身份裁剪; +页面桥传递浏览器 `Headers` 可读取的全部字段,浏览器自身对 Cookie 等字段的规则仍适用。 内置页面桥最多并发 4 个模型请求,每次拉取最多 64 KiB,总期限 10 分钟;页面在已收到响应后 30 秒不继续拉取会取消, 等待首个响应本身不受该空闲计时误杀 @@ -2004,5 +2009,5 @@ GET/HEAD 不接受正文。响应状态码为 200–599,内容类型须在注 `{ "state": "<一次性不透明票据>", "expiresAtMs": 0 }`。state 绑定签发管理身份、实例、制品、版本与路径, 不得记录或截图。公开回调只接受无正文的 GET,查询参数必须恰好包含一个 `state`;过期、错路径、跨实例、 并发重复或已消费票据均拒绝。宿主在执行前原子消费票据,因此超时或插件失败后不能重放。 -公开调用使用无宿主回调权限的独立阶段,不能读取或保存账号、读写状态、发送受管 HTTP 或日志。 +公开调用使用 `public_management` 阶段,插件可以使用宿主回调;票据仍用于关联和消费一次登录流程。 票据用于受控接收第三方返回结果,不代替敏感管理操作的身份校验 diff --git a/docs/architecture.md b/docs/architecture.md index 9e0dcaa7b..639618e77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,7 @@ flowchart TB 依赖方向遵守四条规则: -1. Core 不依赖 HTTP、数据库、Redis 或具体 Provider +1. Core 不依赖 HTTP 服务器/客户端、数据库、Redis 或具体 Provider;中间件端口使用 `http` / `http-body` 的值和流合同,不包含传输实现 2. Provider 之间不互相依赖,也不决定跨 Provider fallback 3. API 只调用 Core/Admin 抽象;Store 只实现端口 4. 具体实现只在组合根相遇 @@ -110,7 +110,7 @@ flowchart LR | 模块 | 负责什么 | 不负责什么 | | --- | --- | --- | | [SDK](../backend/crates/gateway-plugin/sdk/README.md) | 清单、调用数据、消息与帧;可选 `io` 会话辅助 | 不依赖其他 workspace crate,不启动宿主 | -| [Runtime](../backend/crates/gateway-plugin/runtime/src/lib.rs) | 校验包与声明、准备能力集合、RPC、访问域执行与资源回收 | 不持久化安装选择,不重新决定账号资格或费用 | +| [Runtime](../backend/crates/gateway-plugin/runtime/src/lib.rs) | 校验包与声明、准备能力集合、RPC 与资源回收 | 不持久化安装选择,不重新决定账号资格或费用 | | Core | 发布不可变扩展快照;准入、路由、租约、请求交付与计费 | 不解释插件私有协议与存储格式 | | Admin / Store | 制品接受与配置用例 / 事务、版本校验、加密存储与审计 | 缓存目录和进程内状态不能替代数据库事实 | | Host | 下载、受管 HTTP、子进程容量与生命周期、维护任务监督 | 不解释插件业务协议 | @@ -122,12 +122,14 @@ flowchart LR 公开列表、详情、原生目录和受管模型查询复用相同别名与账号访问范围,不另维护插件专用目录 - **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。 发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核 -- **转换声明不替代正文事实**:middleware v2 在 request 阶段声明具体承担的功能和额外上游需求,原始需求保留用于诊断,实际正文与未承担的原始功能仍参与选路。 +- **转换声明不替代正文事实**:middleware v3 在 request 阶段声明具体承担的功能和额外上游需求,原始需求保留用于诊断,实际正文与未承担的原始功能仍参与选路。 attempt 阶段拒绝需求声明。没有改写的正文、header 和响应帧在相同网关边界保留原字节与顺序 -- **制品接受固定访问域**:只读校验不持久化;上传、URL 和 GitHub 的确认安装接受精确摘要声明的访问域, +- **安装意味着完整信任**:只读校验不持久化;上传、URL 和 GitHub 的确认安装记录对精确摘要的信任, 官方发行导入仍须管理员确认。若插件尚无实例,Admin 以稳定 ID 合并 schema 默认值与适用贡献项 binding: - 配置完整则启用,缺少必填项则停用待补;已有实例时不复制配置或切换版本。实例不能增删制品访问域 + 配置完整则启用,缺少必填项则停用待补;已有实例时不复制配置或切换版本 +- **持久化元数据允许字段演进**:Admin 定义已校验制品的读取投影,Store 忽略未知字段;可选字段缺失时沿用清单的缺省语义,身份、摘要和执行所需事实仍为必填,已知字段的类型错误仍报错。 + 读取和重复安装不重写已有 JSON,废弃字段通过迁移清理;新增必填字段或改变语义时须提供迁移。包清单、RPC 与能力合同仍按 SDK 校验,运行兼容性由宿主版本范围及清单、协议、能力版本决定 - **一份发布快照**:数据面和管理页面使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。 新配置准备成功后才提交发布,在途请求持有旧集合直到结束;管理目标和回调另按当前身份与版本复验 - **插件故障按实例隔离**:恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布; @@ -135,26 +137,47 @@ flowchart LR 故障绑定保留原有作用范围和拒绝/委托策略,入口认证故障不降级为其他认证方式;宿主配置存储与 revision 无法确认时仍停止新请求 - **宿主持有业务事实**:插件返回能力结果,Core 继续决定账号资格、发送状态、标准用量与费用;账号变更由 Admin / Store 执行版本校验、事务和审计。Provider 由组合根静态注册为 OpenAI 与 xAI;插件停用不删除账号或历史记录,失败的观察回调不改变响应和账单 -- **能力开放不承接插件业务**:宿主向插件提供独立、可组合的受控原生能力,负责授权、资源隔离、事务与审计。 +- **能力开放不承接插件业务**:宿主向插件提供独立、可组合的原生能力,负责类型校验、资源生命周期、事务与审计。 插件自身的触发条件、跨资源业务关联、策略和流程编排归插件所有;宿主不能因某个插件的使用场景代为实现这些流程 -- **数据加工走中间件**:`request` 包裹逻辑请求,`attempt` 包裹每次已选号的执行;请求顺序进入、响应逆序返回。 - 正文按需读取,透传无需额外读取权限;转换和改写不能绕过 Core 的交付、取消与结算边界。 +- **数据加工走中间件**:Core 的类型化 `compose` 统一维护顺序与单次续体;Runtime 发布时按挂载边界编译有序候选,调用时仅在当前候选中匹配作用域、捕获上下文并拼接续体,协议适配器负责单层 RPC 与资源投影。 + 接入按 HTTP、WebSocket 消息、逻辑请求、上游尝试和主动服务调用的生命周期边界组织。API 在路由匹配前组合所有公开 HTTP 接口; + `http` 可改写路径、方法、完整 headers、惰性上传与响应流,也可直接返回新路由结果或接管自定义 WebSocket 升级。 + 握手、socket 与断连由 API 持有,会话处理通过原 RPC 资源流保活;HTTP 返回链替换响应时释放未提交的会话。 + 原生与插件接管的下游 WebSocket 共用消息组合和取消入口,在默认解析前和实际写入前处理完整消息;读写并发进行,插件调用期间不占用 transport 写锁。 + `request` 包裹逻辑模型请求,`attempt` 包裹每次已选号的执行;请求顺序进入、响应逆序返回。 + 中间件读取和改写完整 headers、正文及响应帧,不进行字段裁剪或额外读取授权;响应正文按需读取,转换和改写保留 Core 的交付、取消与结算事实。 + Provider 来源与执行事件快照可由插件读取,包含费用明细、上游观测、会话状态和原始 wire;读取快照不转移计量和资源所有权。 数据面插件通过受管 HTTP 已发送或无法证明未发送时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 Provider 的 `not_sent` 结果透明重放 +- **主动 HTTP 请求复用总路由**:Runtime 通过 Core 的 `http::Dispatcher` 调用 API 已组装的路由,SDK 的 `dispatch_http` 与 `next` 共用正文资源池。 + 子调用持有父子关联、冻结计划和取消信号,跳过祖先发起实例;模型 request 与 attempt 延续该集合。内部管理请求使用宿主签发的插件身份,审计记录插件与调用来源,普通客户端不能用 headers 声明此身份。 + 正文转交延续真实交付寿命,EOF、显式关闭或父级取消释放资源;组合根保活 Dispatcher,Runtime 只保存 Weak,不增加逐接口注册清单 +- **主动服务调用在注册表统一组合**:Admin 按类型登记操作,Core 组合器只负责续体,Runtime 搬运跨进程调用。 + 插件与 CLI 主动调用设置服务的 11 个操作时进入 `service` 中间件一次;原生 HTTP 接口由总入口包裹,业务方法及其内部调用不重复组合。 + 主动子调用继承冻结计划、父子关联和取消信号,无 service 绑定时也保留空计划快照;调用结束回收资源。 + 各入口复用原用例,运行设置的整体替换仍使用读取版本做事务内比较,冲突不写入。公开范围见 [SDK 服务合同](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#公开服务) - **上游适配器是洋葱链终端**:`upstream_adapter` 只扩展内置 OpenAI / xAI,沿原 Provider 的选号、租约及 attempt 中间件进入冷流。 - Provider 解释凭据、恢复账号状态和计算价格;Runtime 限定目标、绑定账号版本并解析标准事件,Host 负责真实出站。 - 普通 HTTP 与账号 HTTP 共用发送、背压、取消和正文回收,账号鉴权只装饰已授权请求。 + Provider 解释凭据、恢复账号状态和计算价格;Runtime 绑定账号版本并解析标准事件,Host 负责真实出站。 + 普通 HTTP 与账号 HTTP 共用发送、背压、取消和正文回收,账号鉴权先提供默认值,插件可显式覆盖。 WebSocket 连接续接绑定 Key、账号、凭据版本、实例和代次,不能当作可重排的中间件或全局共享句柄。 路由、调度、重试和终态观察保留各自端口,适配器不能另建执行引擎或提交最终账单 -- **访问域不跨调用**:账号、HTTP、模型、私有状态和日志回调绑定有效父调用与阶段。管理页和 CLI 不预绑定 Client Key,模型调用按次 - 选择当前 Key;页面 Responses 桥还复核精确实例目标和 `models` 域,并在等待与交付期间持续撤销检查。 - `frontend_authentication` 必须显式配置 principal 到 Key 的映射;公开登录回调使用一次性票据且不继承宿主回调权限。 - 数据查询、本地预算和上游额度观测分别授权,不建立账号到 Key 的自动同步关系,不承接插件的预测或业务关联。 - 各域的字段、权限与开放阶段由 [SDK 访问域](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)定义 +- **入口设置先形成基线**:`settings::SettingsValues` 保留请求可覆盖的设置事实,编译结果与事实由 `RuntimeSnapshot` 共享持有;读取设置直接借用事实。 + 统一 HTTP 入口从同一冻结快照取得设置和插件计划,`RequestSettings` 持有宿主基线、有效快照及显式改写来源。 + Runtime 将插件改写交回同一设置编译器;API 在认证、版本检查与正文解压前传递有效配置,不按业务接口增加设置包装。HTTP 超时由路由终端消费,`None` 清除本次超时。 + OpenAI 的 Fast 设置在 attempt 中间件前形成正文基线;OpenAI / xAI 的插件头修改覆盖账号与画像生成的同名头,不按字段名拦截。 + HTTP 正文的寿命覆盖惰性读取,WebSocket 升级后由独立连接取消信号和冻结发布代次接管,101 响应正文结束不会取消会话 +- **模型设置按请求重算**:Key 自身的画像、Fast 和限额默认值独立保留;`RequestSettings` 统一按宿主默认、当前 Key 默认、插件显式覆盖的顺序解析。 + 认证、插件使用的 `ExecutionSettings` 视图和执行终端共用该规则。设置改写经过编译后由终端复用结果,截止时间仍相对请求开始时刻计算,不发布全局配置。 + 子调用保留显式改写,在目标 Key 作用域重算默认值;Key 限额、Fast 和模型超时的覆盖绑定原 Key,同级调用互不改写。 + 原生 Responses WebSocket 每轮从当前快照重新认证,保留握手的显式改写;已经开始的执行不随全局发布改变。具体字段与来源合同见 [SDK 中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#http-入口) +- **资源跟随调用**:账号、HTTP、模型、状态和日志回调绑定有效父调用,取消随调用传播。受管流建立后不沿用握手的绝对期限,网络操作与模型执行各自计时。 + 显式 Key 的身份快照随调用保留,每个模型执行拥有独立子调用图与结算;省略 Key 时继承现有模型父请求。页面 Responses 桥复核精确实例目标和 revision + `frontend_authentication` 使用 principal 到 Key 的映射;公开登录回调的一次性票据用于关联流程,不限制插件回调能力。接口合同见 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#宿主资源回调) +- **观察合同统一**:`observer` 通过一个类型化 RPC 入口接收完成与实际上游 WebSocket 事件,发布时按事件类型编译独立作用域。完成事件直接携带终态、用量、费用和耗时,不按能力声明裁剪字段;异步投递的并发、队列和载荷有界,观察失败不改变业务结果 - **维护只作用于已发布实例**:`maintenance` 由 Host 监督的 Runtime worker 调用,启用、恢复、配置发布和周期补偿共用对账入口。 发布通知只用于唤醒,事实始终来自当前快照与数据库;同实例串行,通知合并,停用与版本替换取消旧任务。 自有分组和 Key 按实例、资源类型与稳定资源键登记;Admin 组织用例,Store 在同一事务复核当前启用 revision、 - 制品授权与归属,并复用原生资源写入、审计和配置发布。无变化不递增 revision;插件不能接管管理员资源, + 制品接受事实与归属,并复用原生资源写入、审计和配置发布。无变化不递增 revision;插件不能接管管理员资源, 成员变更不覆盖账号的其他分组。停用保留资源;删除实例只解除归属 - **版本切换保持数据一致**:同一插件最多启用一个实例;每个实例与制品摘要保存最近启用时提交的配置、密钥和绑定, 快照与实例修改共享事务,停用草稿不覆盖恢复点。版本切换优先恢复目标快照,否则合并缺失默认值并保留显式设置。 @@ -163,8 +186,8 @@ flowchart LR #### 信任与资源边界 -插件以与宿主相同的 OS 身份运行;接受制品访问域是管理员确认,不是操作系统沙箱。 -进程、RPC、下载、缓存与回调均有容量和期限限制;Host 提供受管资源,Runtime 负责协议、访问域与调用上下文校验。 +插件以与宿主相同的 OS 身份运行,安装者承担插件安全;宿主不提供插件安全沙箱或访问域隔离。 +进程、RPC、下载、缓存与回调均有容量和期限限制;Host 提供受管资源,Runtime 负责协议、调用关联与资源生命周期。 下载来源、凭据与代理按目标匹配,网络操作不占用数据库事务,提交前重新校验来源与代理版本 管理页面使用无同源权限的 sandbox iframe,通过固定目标的宿主桥访问已声明路由; @@ -209,12 +232,14 @@ HTTP 字段见 [插件 API](api.md#12-插件管理),更新与数据恢复见 [ | `validation` | 纯值对象校验错误和文本约束,不依赖事件、执行错误或路由 | | `identity` | Provider 身份值 `ProviderKind`,只依赖纯校验 | | `account` | Provider 账号/credential/quota 值对象、持久化端口与请求级账号选择;`scope` 持有分组、账号目录和冻结账号范围 | +| `policy` | Client API Key 准入、原始 Key 设置与客户端版本策略;只使用账号范围、Provider 身份和基础校验 | | `metering` | 标准化 Usage、金额、费用估算与费用明细;不表示账号或开票系统 | | `upstream` | 跨 Engine、Event、Error 与 Provider 共用的 transport 名称、发送状态和不透明上游值 | | `lifecycle` | 取消信号、连接注册与 drain 合同 | | `engine` | attempt、发送/提交屏障、执行编排和持久化调用时序 | | `routing` | 冻结路由事实、请求计划、Provider 只读目录合同以及运行时快照的表示与编译 | | `runtime` | 当前快照的发布、读取、revision 订阅与周期对账任务 | +| `settings` | 请求设置事实及其编译、宿主与 Key 默认值解析、显式覆盖来源;由 `routing` 快照组合并冻结 | `event` 通过 `validation` / `upstream` 使用基础值,不依赖承载原始事件的执行错误;账号值对象和 选择策略通过 `identity` / `account::scope` 使用身份与范围,不依赖路由计划。`routing` 和 `error` 的 @@ -526,7 +551,7 @@ Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准 Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限额配置。 Admin 的手动重置复用同一账本与 Key 行锁,在一个事务中清零所选周期金额、推进计费起点并写入审计, 保留窗口到期时间与费用事件,不推进配置 revision。结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期。 -插件预算回调通过 `PluginClientKeyAccess` 进入同一 `ClientKeyService` 和 `ClientKeyStore`,存储事务先复用插件写入授权, +插件预算回调通过 `PluginClientKeyAccess` 进入同一 `ClientKeyService` 和 `ClientKeyStore`,存储事务先复核插件实例版本, 再执行原生预算操作;不建立另一份预算状态。查询复用账本投影,不触发准入。局部更新金额上限不改用量或窗口, 仅在值变化时提交配置 revision、审计并发布;重置仍不推进配置版本。重复调用及结果未知的合同见 [SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算) @@ -603,7 +628,7 @@ PostgreSQL 周期对账才是正确性基础 | --- | --- | --- | | 账号、credential、分组、Client Key、设置、审计、请求与备份记录 | PostgreSQL | 业务持久化事实 | | Client Key 金额窗口与费用事件 | PostgreSQL | 准入与幂等结算的权威账本,独立于请求观测与日志保留策略 | -| 插件包体、安装来源、制品接受事实、实例配置与私有状态 | PostgreSQL | 制品访问域由已接受摘要派生;进程内发布集合由持久化事实构建 | +| 插件包体、安装来源、制品接受事实、实例配置与私有状态 | PostgreSQL | 完整信任决定绑定已接受摘要;进程内发布集合由持久化事实构建 | | admission、lease、cooldown、circuit、会话亲和、continuation、OAuth pending、目录 cache | Redis | 可重建、可过期的协调状态 | | 控制面统一登录会话与登录限流桶 | Redis | AuthService 唯一拥有;保存 Admin / Key 身份、绑定 ID、绝对有效期和计数,不保存原始凭据 | | 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 | diff --git a/docs/plugins.md b/docs/plugins.md index f69201c08..9d559a940 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,13 +1,13 @@ # 插件使用 -插件“制品”是按 SHA-256 固定的代码、版本和访问域;“配置”是运行实例,保存普通设置、敏感设置和功能范围。 -日常使用只维护一个当前配置,同一插件最多启用一个实例。访问域随制品接受,不能在设置中另行增删 +插件“制品”是按 SHA-256 固定的代码与版本;“配置”是运行实例,保存普通设置、敏感设置和功能范围。 +日常使用只维护一个当前配置,同一插件最多启用一个实例 -[安装](#安装) · [访问域](#访问域) · [配置与启用](#配置与启用) · [使用](#使用) · [更新与卸载](#更新回退与卸载) +[安装](#安装) · [完整信任](#完整信任) · [配置与启用](#配置与启用) · [使用](#使用) · [更新与卸载](#更新回退与卸载) ```mermaid flowchart LR - A[选择来源] --> B[校验包并查看访问域] + A[选择来源] --> B[校验包并确认来源] B --> C[确认安装并接受制品] C --> D[生成默认配置] D -->|配置完整| E[启用并发布] @@ -29,48 +29,39 @@ flowchart LR 公开来源无需认证。私有来源可选择或创建独立下载认证;认证按来源和用途限制,不会交给插件进程。 下载代理独立选择,默认直连,代理失败不会回退直连。SHA-256 用于固定内容,不证明发布者身份 -“下一步”只解析和校验包,不保存制品。检查名称、发布者、版本、平台、摘要和访问域后确认安装;上传、URL -和 GitHub 安装会接受该摘要声明的访问域。随宿主发行物导入的官方制品会显示为“待接受”,也必须先查看访问域并确认, +“下一步”只解析和校验包,不保存制品。检查名称、发布者、版本、平台和摘要后确认安装。随宿主发行物导入的官方制品会显示为“待接受”,也必须先确认信任来源, 未接受的制品不能创建或编辑配置。相同摘要的确认可安全重试 确认安装后,若该插件还没有任何配置,宿主会: 1. 从 schema 填入普通配置默认值,不把敏感字段的默认值写入普通配置 -2. 为需要实例范围的贡献项生成默认绑定;管理页面、命令行和自动维护按声明注册,入口认证不自动绑定 +2. 为需要实例范围的贡献项生成默认绑定;模型目录、管理页面、命令行和自动维护按声明注册,入口认证不自动绑定 3. 配置完整时直接启用并发布;仅缺少必填项时保存为停用并打开配置页 类型错误、非法 schema 或把敏感值混入普通配置会直接拒绝,不会伪装成“待配置”。更新已有插件时不会创建另一套配置, 管理端在包安装完成后提示确认切换,检查通过后应用目标版本,不兼容时保留原版本并打开待调整的设置 -### 访问域 +### 完整信任 -安装确认只使用以下稳定访问域;具体调用仍受贡献项、当前调用上下文和宿主策略限制 +插件以网关的系统身份运行,能够访问数据、凭据、配置和网络。安装即信任插件的代码与行为,安全由安装者承担;宿主不提供插件安全沙箱,也不通过权限清单承诺隔离 -| 标识 | 安装摘要中的含义 | -| --- | --- | -| `network` | 访问网络 | -| `models` | 查询模型和 Client Key 基本信息并调用模型,可能产生消耗 | -| `accounts` | 读取和修改账号,包括访问原始凭据 | -| `data` | 仅在管理、命令或维护入口只读全部账号的基础信息和已有额度观测,不含凭据或预测 | -| `requests` | 查看和处理请求、响应、路由及账号选择 | -| `public_endpoints` | 提供无需登录即可访问的资源与回调入口 | -| `groups` | 创建插件自有分组,可将所有现有及未来账号加入或移出这些分组 | -| `keys` | 创建绑定插件自有分组的 Client Key,不读取密钥明文 | - -访问域不是操作列表或资源白名单。实例配置不能扩大、缩小或伪造制品声明;切换版本后使用目标制品已经接受的精确声明。 -插件进程与网关使用相同的系统身份,接受访问域不是操作系统沙箱,因此只安装可信来源的包 +包格式、摘要和兼容性校验用于确认可加载性,不证明插件安全。只安装可信来源,并在升级前确认目标版本 ## 配置与启用 在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口 “设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持 -原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;有特殊需求时,可在“高级设置”中调整请求作用范围。 +原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;可在“高级设置”中调整各挂载阶段的启停、顺序和失败处理。 +模型请求阶段支持 Key、分组、模型等作用范围;HTTP、WebSocket 消息和服务调用全局生效,具体匹配由插件处理。 客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可 +“事件观察”分别设置“请求完成与用量”和“上游 WebSocket 事件”;关闭其中一类不影响另一类。 +观察用于接收事实;请求、响应或消息改写使用对应的中间件 + 范围数组为空表示不限制该维度。自动创建的默认绑定不预先绑定 Client Key;普通网关请求仍以实际请求的 Key 和冻结策略 执行。管理页面和命令行不保存功能 binding,页面调用模型时由页面每次明确选择一个 Client Key ID,宿主按当前 Key 状态、 -范围、预算、账本和普通请求链执行,插件看不到 Key 明文 +范围、预算、账本和普通请求链执行,页面桥使用 Key ID `frontend_authentication` 不会随安装自动接管入口。启用它时必须明确配置外部 principal 到既有 Client Key 的映射; 已停用或删除的 Key 不会因此恢复访问 @@ -79,7 +70,7 @@ flowchart LR 若同一插件已有多个启用实例,需先整理为一套才能继续启用或更新,宿主不会自动停用实例或删除配置、密钥与私有数据 -声明自动维护的插件可自行准备已授权的分组和 Key,无需提供管理员密码。停用会停止维护,但保留这些资源及其原生启用状态; +声明自动维护的插件可自行准备分组和 Key,无需提供管理员密码。停用会停止维护,但保留这些资源及其原生启用状态; 删除插件配置也不自动删除资源。需要撤销 Key 访问时,在 Key 管理中停用或删除相应 Key “已启用”表示配置已经发布,不是外部服务的健康证明。“等待生效”表示候选仍在准备;“需要处理”会显示发布或运行失败原因。 @@ -98,8 +89,9 @@ flowchart LR | 入口认证 | 客户端按插件协议访问,宿主把明确的 principal 映射到 Client Key | | 命令行 | 在宿主环境运行 `codex-proxy-rs plugin <配置 ID> --help` | -页面模型桥要求管理会话、当前实例/制品/revision 和 `models` 访问域同时有效。停用实例、切换版本或 revision、 -撤销相应访问资格后,宿主会在等待首帧和交付响应期间持续复核并取消请求;它不会把管理 Cookie、Key 明文或认证头交给插件 +页面模型桥要求管理会话、当前实例/制品/revision 均有效。停用实例、切换版本或 revision、 +目标失效后,宿主会在等待首帧和交付响应期间持续复核并取消请求。页面用 Key 标识选择推理身份; +插件中间件仍可读取完整请求头并改写响应,页面桥不提供额外的插件安全隔离 ## 更新、回退与卸载 @@ -107,13 +99,16 @@ flowchart LR - **检查更新**:GitHub 按保存的策略查询发布,URL 下载并解析当前地址的包;本地上传不支持查询。检查本身不会安装,GitHub 标签也不等于包内版本 - **更新插件**:选择新包并确认安装,再确认切换到目标版本。保留已填写的值,补充新版本缺失的默认值;字段改名、类型改变或删除等不兼容情况由用户调整,不猜测或静默清空参数与密钥 -- **切换版本**:在版本列表中选择已安装版本,确认当前版本与目标版本后执行。若该版本已有恢复设置,使用它上次启用时提交的参数、密钥与功能范围;否则从当前设置生成草稿。切换保持启停状态,目标访问域以已接受的包为准。停用的历史配置引用旧版本时显示“历史配置”,不表示该版本正在运行 +- **切换版本**:在版本列表中选择已安装版本,确认当前版本与目标版本后执行。若该版本已有恢复设置,使用它上次启用时提交的参数、密钥与功能范围;否则从当前设置生成草稿。切换保持启停状态,目标制品必须已接受。停用的历史配置引用旧版本时显示“历史配置”,不表示该版本正在运行 - **回退版本**:只列出同一插件已安装、已接受且保留恢复设置的较早版本,恢复对应设置后重新执行兼容检查 每个精确插件包保留最近一次启用时提交的配置快照,停用期间未完成的草稿不会覆盖恢复点 宿主升级仅能为当前启用配置建立恢复点,无法还原未保存或已丢失的其他版本设置 +宿主元数据的可选字段增减不要求重新安装插件;清单、协议、能力或私有状态不兼容仍须处理。 +宿主升级前的检查与恢复要求见[插件兼容与发行目录](../deploy/README.md#插件兼容与发行目录) + 修改下载来源会先单独保存,取消后续安装也会保留已保存来源。当前浏览器标签页 记住未提交的安装参数和凭据引用,不保存认证明文或上传文件 diff --git a/frontend/src/api/modules/plugins.ts b/frontend/src/api/modules/plugins.ts index 55552a3a0..fe4637112 100644 --- a/frontend/src/api/modules/plugins.ts +++ b/frontend/src/api/modules/plugins.ts @@ -24,12 +24,6 @@ export interface PluginContribution { export type PluginIconAsset = string | { light: string, dark: string } -export interface PluginPermissionDescription { - permission: string - label: string - description: string -} - export interface PluginArtifactMetadata { pluginId: string version: string @@ -43,8 +37,6 @@ export interface PluginArtifactMetadata { sha256: string platforms: string[] contributes: Record - requestedPermissions: string[] - permissionDescriptions: PluginPermissionDescription[] configurationSchema: Record secretFields: string[] stateNamespaces: { @@ -180,7 +172,10 @@ export interface PluginFrontendIdentityBinding { clientKeyId: string } +export type PluginObserverEvent = 'request_completed' | 'websocket_response' + export interface PluginCapabilityBinding { + event?: PluginObserverEvent | null contribution: string stage: string order: number diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index b34bd6351..8a9c519dc 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -16,6 +16,7 @@ export interface SmartSchedulingConfig { } export interface RuntimeSettings { + configRevision: number smartScheduling: SmartSchedulingConfig smartSchedulingDefaults: SmartSchedulingConfig providerRequestProfiles: ProviderRequestProfiles diff --git a/frontend/src/views/plugins/components/PluginBindingEditor.vue b/frontend/src/views/plugins/components/PluginBindingEditor.vue index f58548fa5..e9c3083c4 100644 --- a/frontend/src/views/plugins/components/PluginBindingEditor.vue +++ b/frontend/src/views/plugins/components/PluginBindingEditor.vue @@ -2,7 +2,7 @@ import type { PluginArtifactMetadata, PluginCapabilityBinding } from '@/api' import { BaseCheckbox, BaseFormItem, BaseNumberInput, BaseSelect, BaseSwitch } from '@codex-proxy/ui' import { computed, ref, watch } from 'vue' -import { pluginCapabilityLabel } from '../utils/model' +import { pluginCapabilityLabel, pluginRequestBindingEntries } from '../utils/model' import PluginHelpPopover from './PluginHelpPopover.vue' import PluginResourcePicker from './PluginResourcePicker.vue' import PluginScopeInput from './PluginScopeInput.vue' @@ -10,23 +10,9 @@ import PluginScopeInput from './PluginScopeInput.vue' const props = defineProps<{ metadata: PluginArtifactMetadata, disabled?: boolean }>() const emit = defineEmits<{ validityChange: [valid: boolean] }>() const bindings = defineModel({ required: true }) -const stages: Record = { - middleware: ['request', 'attempt'], - upstream_adapter: ['upstream'], - model_router: ['routing'], - scheduler: ['scheduling'], - retry_policy: ['retry'], - request_lifecycle: ['observation'], - usage: ['observation'], - web_socket_observer: ['observation'], -} -const stageLabels: Record = { request: '请求开始', attempt: '每次尝试', upstream: '上游调用', routing: '模型路由', scheduling: '账号调度', retry: '重试决策', observation: '请求结束' } -const entries = computed(() => Object.entries(props.metadata.contributes).flatMap(([capability, contribution]) => - contribution.stages.filter(stage => stages[capability]?.includes(stage)) - .map(stage => ({ capability, contribution: contribution.id, stage, key: `${contribution.id}:${stage}` })), -)) -function keyOf(binding: PluginCapabilityBinding) { - return `${binding.contribution}:${binding.stage}` +const entries = computed(() => pluginRequestBindingEntries(props.metadata).map(entry => ({ ...entry, key: keyOf(entry) }))) +function keyOf(binding: Pick) { + return `${binding.contribution}:${binding.stage}:${binding.event ?? ''}` } function hasScope(binding: PluginCapabilityBinding) { return Boolean(binding.clientKeyIds.length || binding.accountGroupIds.length || binding.models.length || binding.providerIds.length) @@ -46,7 +32,7 @@ function toggle(entry: typeof entries.value[number], enabled: boolean) { bindings.value = bindings.value.filter(binding => keyOf(binding) !== entry.key) if (enabled) { const order = entry.stage === 'observation' ? bindings.value.find(binding => binding.stage === 'observation')?.order ?? 0 : 0 - bindings.value = [...bindings.value, { contribution: entry.contribution, stage: entry.stage, order, failurePolicy: entry.stage === 'observation' ? 'observe' : entry.stage === 'retry' ? 'delegate' : 'reject', providerIds: [], models: [], clientKeyIds: [], accountGroupIds: [], identityBindings: [] }] + bindings.value = [...bindings.value, { contribution: entry.contribution, stage: entry.stage, event: entry.event, order, failurePolicy: entry.stage === 'observation' ? 'observe' : entry.stage === 'retry' ? 'delegate' : 'reject', providerIds: [], models: [], clientKeyIds: [], accountGroupIds: [], identityBindings: [] }] } } function setGlobal(key: string, value: boolean) { @@ -68,7 +54,7 @@ function setGlobal(key: string, value: boolean) { } const valid = computed(() => entries.value.every((entry) => { const binding = bindingFor(entry.key) - return !binding || globalScopes.value.has(entry.key) || hasScope(binding) + return !binding || entry.scope === 'none' || globalScopes.value.has(entry.key) || hasScope(binding) })) watch(valid, value => emit('validityChange', value), { immediate: true }) @@ -77,27 +63,30 @@ watch(valid, value => emit('validityChange', value), { immediate: true })
- + 仅观察请求,不改变请求结果