diff --git a/.agents/skills/plugin-dev/SKILL.md b/.agents/skills/plugin-dev/SKILL.md index 3b949bf7c..609c6cbdc 100644 --- a/.agents/skills/plugin-dev/SKILL.md +++ b/.agents/skills/plugin-dev/SKILL.md @@ -35,6 +35,9 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编 | --- | --- | --- | | 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) | | 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 | +| 发布模型别名 | `model_catalog` | [模型目录](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录),注册时冻结直接目标,不需要请求绑定 | +| 读取基础数据制作管理页面 | `management` + `data` 权限 | [基础事实](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#基础事实),不需要账号凭据授权 | +| 选择宿主允许的重试动作 | `retry_policy` | [重试决策](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#重试决策),不能修改预算或自行重复调用 next | | 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 | | 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 | | 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) | @@ -60,7 +63,7 @@ description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编 - `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用 - `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域 - 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移) -- 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务 +- 权限只声明 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务 - 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用) - SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖 - 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份 diff --git a/backend/crates/gateway-admin/src/ports/plugin_accounts.rs b/backend/crates/gateway-admin/src/ports/plugin_accounts.rs index 1c1586f36..a18ccd727 100644 --- a/backend/crates/gateway-admin/src/ports/plugin_accounts.rs +++ b/backend/crates/gateway-admin/src/ports/plugin_accounts.rs @@ -26,6 +26,14 @@ pub trait PluginAccountAccess: Send + Sync { account_id: &ProviderAccountId, ) -> Result; + /// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。 + async fn get_quota( + &self, + _account_id: &ProviderAccountId, + ) -> Result { + Err(AdminError::unavailable("插件额度事实查询暂不可用")) + } + async fn save( &self, command: PreparedPluginAccountSave, diff --git a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs index c49b664cd..b454d9025 100644 --- a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs @@ -95,6 +95,20 @@ impl PluginAccountAccess for DefaultPluginAccountAccess { }) } + async fn get_quota( + &self, + account_id: &ProviderAccountId, + ) -> Result { + let account = self.get_runtime(account_id).await?; + self.provider(&account.provider_kind)? + .quota(crate::model::provider_credentials::ProviderQuotaRequest { + account_id: account_id.clone(), + refresh: false, + rolling_usage: None, + }) + .await + .map_err(|error| map_provider_error(error, "plugin quota facts")) + } async fn save( &self, command: PreparedPluginAccountSave, 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 c12d09baf..64624ea8c 100644 --- a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs @@ -377,7 +377,7 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec Vec Result { + self.operation = request.apply_capabilities(self.operation)?; + Ok(self) + } + /// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态。 #[must_use] pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self { 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 956ddfae4..4b319d598 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs @@ -278,7 +278,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke input, Box::new(move |prepared, request| { Box::pin(async move { - let (protocol, headers, body) = request_parts(request)?; + let (protocol, headers, body) = request_parts(request.clone())?; if protocol != "openai" { return Err(MiddlewareError::Rejected); } @@ -291,7 +291,9 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke let decoded = decode_response_create_with_context(payload, &request_headers) .map_err(|_| MiddlewareError::Rejected)? .with_client_context(client_ip, user_agent_for_terminal); - let decoded = replay_for_terminal.prepare(decoded); + let decoded = replay_for_terminal + .prepare(decoded) + .with_middleware_capabilities(&request)?; let started = service_for_terminal .start_prepared_response( prepared, diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index cb4784dfe..7dc21de11 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -1062,25 +1062,45 @@ where Ok(stream) => stream, Err(error) => { record_trace_error(&attempt_trace, &error); - if self.prepare_unavailable_native_continuation_replay(&error) { - return Ok(Some(PullOutcome::AttemptDiscarded)); - } - if matches!( - error.kind(), - ProviderErrorKind::AccountCapacityUnavailable - | ProviderErrorKind::NoEligibleAccount - | ProviderErrorKind::QuotaExhausted - | ProviderErrorKind::ProviderInfrastructureUnavailable - | ProviderErrorKind::ConcurrencyQueueFull - | ProviderErrorKind::ConcurrencyQueueTimeout - ) && error.send_state() == UpstreamSendState::NotSent + let continuation_retry = + self.prepare_unavailable_native_continuation_replay(&error); + let candidate_retry = !continuation_retry + && matches!( + error.kind(), + ProviderErrorKind::AccountCapacityUnavailable + | ProviderErrorKind::NoEligibleAccount + | ProviderErrorKind::QuotaExhausted + | ProviderErrorKind::ProviderInfrastructureUnavailable + | ProviderErrorKind::ConcurrencyQueueFull + | ProviderErrorKind::ConcurrencyQueueTimeout + ) + && error.send_state() == UpstreamSendState::NotSent && self.current_send_state() == UpstreamSendState::NotSent && matches!( self.continuation_attempt, ContinuationAttempt::None | ContinuationAttempt::ReplayAny ) - && self.advance_provider_candidate() - { + && self.advance_provider_candidate(); + let retryable = self + .apply_retry_policy(super::policy::RetryFacts { + attempt_index: next_attempt, + provider: candidate.provider().clone(), + model: candidate + .upstream_model() + .map(|model| model.as_str().to_owned()), + error_kind: error.kind(), + upstream_status: error.upstream_status(), + send_state: error.send_state(), + remaining_routing_attempts: self + .plan + .max_attempts() + .get() + .saturating_sub(self.routing_attempts), + remaining_deadline: Duration::ZERO, + retry_allowed: continuation_retry || candidate_retry, + }) + .await?; + if retryable { return Ok(Some(PullOutcome::AttemptDiscarded)); } if matches!( @@ -1511,6 +1531,35 @@ where || account_rotation_retry || transport_recovery.is_some(); + let retryable = match self + .apply_retry_policy(super::policy::RetryFacts { + attempt_index: current.index, + provider: current.metadata.provider().clone(), + model: current + .metadata + .upstream_model() + .map(|model| model.as_str().to_owned()), + error_kind: error.kind(), + upstream_status: error.upstream_status(), + send_state: attempt_send_state, + remaining_routing_attempts: self + .plan + .max_attempts() + .get() + .saturating_sub(self.routing_attempts), + remaining_deadline: Duration::ZERO, + retry_allowed: retryable, + }) + .await + { + Ok(retryable) => retryable, + Err(error) => { + self.current = Some(current); + self.finish_interruption(&error).await?; + return Err(error); + } + }; + self.trace.attempt(current.index.get()).record("retry.decided", json!({ "retryable": retryable, "continuationRetry": continuation_retry, "sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry, @@ -1608,6 +1657,41 @@ where Err(provider_engine_error(error)) } + async fn apply_retry_policy( + &mut self, + mut facts: super::policy::RetryFacts, + ) -> Result { + let Some(policy) = self.request_policy.clone() else { + return Ok(facts.retry_allowed); + }; + facts.remaining_deadline = self + .deadline + .duration_since(SystemTime::now()) + .unwrap_or_default(); + facts.retry_allowed &= !facts.remaining_deadline.is_zero() + && !self.cancellation.is_cancelled() + && !self.execution_effect_observed(); + let allowed = facts.retry_allowed; + let decision = { + let cancellation = self.cancellation.clone(); + let cancelled = cancellation.cancelled().fuse(); + let decision = policy.retry_decision(facts).fuse(); + let mut deadline_timer = &mut self.deadline_timer; + pin_mut!(cancelled, decision); + select_biased! { + () = cancelled => Err(EngineError::Cancelled), + () = deadline_timer => Err(EngineError::Deadline), + decision = decision => Ok(decision), + } + }?; + // 策略只收窄宿主已允许的恢复,返回后仍复核期限和外部副作用。 + Ok(allowed + && decision != super::policy::RetryDecision::Stop + && !self.execution_effect_observed() + && !self.cancellation.is_cancelled() + && SystemTime::now() < self.deadline) + } + fn observe_atomic_terminal_events(&mut self, events: &mut [ProviderEvent]) { for event in events { if let Some(observation) = event.take_observation() { diff --git a/backend/crates/gateway-core/src/engine/middleware.rs b/backend/crates/gateway-core/src/engine/middleware.rs index 066bfff5a..c1e089d0e 100644 --- a/backend/crates/gateway-core/src/engine/middleware.rs +++ b/backend/crates/gateway-core/src/engine/middleware.rs @@ -4,7 +4,7 @@ //! Runtime/SDK 负责;身份、路由、账号租约、发送事实、重试、计量与结算仍由 Core //! 持有,不能通过本端口交给插件解释。 -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::num::NonZeroU32; use std::sync::{Arc, RwLock, Weak}; @@ -23,7 +23,9 @@ use crate::error::{GatewayError, ProviderError}; use crate::event::ProviderEvent; use crate::identity::ProviderKind; use crate::lifecycle::CancellationToken; -use crate::operation::OperationKind; +use crate::operation::{ + CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, ProtocolPayload, +}; use crate::policy::ClientApiKeyId; use crate::runtime::extensions::{ExtensionSetId, ExtensionSetReference}; @@ -95,13 +97,27 @@ pub struct MiddlewareRequest { protocol: String, headers: Vec, body: Bytes, + original_body: Bytes, + original_protocol: String, + capabilities: Option, +} + +/// 只声明具体功能的责任归属,实际正文中的需求始终继续生效。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MiddlewareCapabilityDeclaration { + pub handled: BTreeSet, + pub required: BTreeSet, } impl MiddlewareRequest { #[must_use] pub fn new(protocol: impl Into, headers: Vec, body: Bytes) -> Self { + let protocol = protocol.into(); Self { - protocol: protocol.into(), + original_protocol: protocol.clone(), + original_body: body.clone(), + capabilities: None, + protocol, headers, body, } @@ -126,6 +142,78 @@ impl MiddlewareRequest { pub fn into_parts(self) -> (String, Vec, Bytes) { (self.protocol, self.headers, self.body) } + + /// 保留链入口的语义来源;新增声明必须对应当前这一层实际消除的功能字段。 + pub fn replace_parts( + mut self, + protocol: String, + headers: Vec, + body: Bytes, + declaration: Option, + ) -> Result { + if let Some(declaration) = declaration { + if self.protocol != "openai" + || protocol != "openai" + || self.original_protocol != "openai" + || declaration.handled.contains(&Feature::NativeContinuation) + || !declaration.handled.is_disjoint(&declaration.required) + || (declaration.handled.is_empty() && declaration.required.is_empty()) + { + return Err(MiddlewareError::InvalidState); + } + let before = generate_requirements(&self.body)?; + let after = generate_requirements(&body)?; + if !declaration.handled.is_subset(before.features()) + || !declaration.handled.is_disjoint(after.features()) + { + return Err(MiddlewareError::InvalidState); + } + let accumulated = self.capabilities.get_or_insert_default(); + accumulated.handled.extend(declaration.handled); + accumulated.required.extend(declaration.required); + } + self.protocol = protocol; + self.headers = headers; + self.body = body; + Ok(self) + } + + /// API 在最终解码后应用;没有声明的透传路径不解析或重新编码正文。 + pub fn apply_capabilities(&self, operation: Operation) -> Result { + let Some(declaration) = &self.capabilities else { + return Ok(operation); + }; + if operation.kind() != OperationKind::Generate || operation.protocol() != "openai" { + return Err(MiddlewareError::InvalidState); + } + let original = generate_requirements(&self.original_body)?; + let mut effective = CapabilityRequirements::new(OperationKind::Generate); + for feature in original + .features() + .difference(&declaration.handled) + .chain(declaration.required.iter()) + { + effective = effective.require(*feature); + } + operation + .with_middleware_requirements(original, effective) + .map_err(|_| MiddlewareError::InvalidState) + } + + #[must_use] + pub fn has_capability_declaration(&self) -> bool { + self.capabilities.is_some() + } +} + +fn generate_requirements(body: &[u8]) -> Result { + let body = serde_json::from_slice(body).map_err(|_| MiddlewareError::InvalidState)?; + let payload = + ProtocolPayload::json_object("openai", body).map_err(|_| MiddlewareError::InvalidState)?; + Ok( + Operation::Generate(GenerateRequest::from_protocol_payload(payload)) + .capability_requirements(), + ) } impl fmt::Debug for MiddlewareRequest { diff --git a/backend/crates/gateway-core/src/engine/policy.rs b/backend/crates/gateway-core/src/engine/policy.rs index 1c487af6d..41dc6c5ed 100644 --- a/backend/crates/gateway-core/src/engine/policy.rs +++ b/backend/crates/gateway-core/src/engine/policy.rs @@ -235,6 +235,37 @@ pub enum AccountScheduleDecision { Reject, } +/// 重试策略只能收窄宿主决定;不能自行创造恢复路径。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetryDecision { + Delegate, + Stop, + Retry, +} + +/// 协调器计算的安全失败事实;无凭据、原始错误或请求正文。 +#[derive(Debug, Clone)] +pub struct RetryFacts { + pub attempt_index: NonZeroU32, + pub provider: ProviderKind, + pub model: Option, + pub error_kind: crate::error::ProviderErrorKind, + pub upstream_status: Option, + pub send_state: super::UpstreamSendState, + pub remaining_routing_attempts: u32, + pub remaining_deadline: std::time::Duration, + pub retry_allowed: bool, +} + +#[derive(Debug, Clone)] +pub struct RetryInput { + pub request_id: super::ModelRequestId, + pub client_key_id: ClientApiKeyId, + pub account_group_ids: Arc<[AccountGroupId]>, + pub extension_scope: ExtensionCallScope, + pub facts: RetryFacts, +} + /// 策略调用失败不携带插件原始消息,避免跨层泄漏不可信诊断。 #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("request policy call failed")] @@ -253,6 +284,13 @@ pub enum AccountPolicyError { /// 一个发布代次的不可变请求策略计划。 pub trait RequestPolicyPlan: Send + Sync + fmt::Debug { + fn retry_decision( + &self, + _input: RetryInput, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Ok(RetryDecision::Delegate) }) + } + fn route_model( &self, input: ModelRouteInput, @@ -288,6 +326,25 @@ impl fmt::Debug for RequestPolicyContext { } impl RequestPolicyContext { + pub async fn retry_decision(&self, facts: RetryFacts) -> RetryDecision { + self.plan + .retry_decision(RetryInput { + request_id: self.request_id.clone(), + client_key_id: self.client_key_id.clone(), + account_group_ids: self.account_group_ids.clone(), + extension_scope: self.extension_scope.clone(), + facts, + }) + .await + .unwrap_or_else(|_| { + tracing::warn!( + request_id = self.request_id.as_str(), + "重试策略调用失败,使用宿主决定" + ); + RetryDecision::Delegate + }) + } + #[must_use] pub fn new( plan: Arc, diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index c455c321b..d988aaa53 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -479,6 +479,9 @@ impl MiddlewareNext for ProviderMiddlewareNext { ) -> BoxFuture<'static, Result> { Box::pin(async move { let fallback_protocol = self.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 diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index 980ad70f4..3191cbe95 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -336,7 +336,10 @@ impl CoreControlPlaneStartup { match self.publisher.refresh().await { Ok(_) => {} // 故障插件不能封锁管理修复入口;没有可用快照时数据面仍拒绝新请求。 - Err(routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable) => {} + Err( + routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable + | routing::snapshot::RuntimeSnapshotCompileError::InvalidExtensionModels, + ) => {} Err(_) => return Err(CoreError::SnapshotUnavailable), } Ok(CoreControlPlaneBundle { diff --git a/backend/crates/gateway-core/src/operation/mod.rs b/backend/crates/gateway-core/src/operation/mod.rs index e5baedb2f..abfe31738 100644 --- a/backend/crates/gateway-core/src/operation/mod.rs +++ b/backend/crates/gateway-core/src/operation/mod.rs @@ -374,6 +374,7 @@ struct GeneratePayload { protocol_payload: ProtocolPayload, provider_session_state: Option, source_requirements: Option, + middleware_requirements: Option<(CapabilityRequirements, CapabilityRequirements)>, } impl GenerateRequest { @@ -388,6 +389,7 @@ impl GenerateRequest { protocol_payload, provider_session_state: None, source_requirements: None, + middleware_requirements: None, }), } } @@ -487,6 +489,11 @@ impl GenerateRequest { } let mut requirements = CapabilityRequirements::new(OperationKind::Generate) .with_requested_output_tokens(self.max_output_tokens()); + if let Some((_, inherited)) = &self.payload.middleware_requirements { + for feature in inherited.features() { + requirements = requirements.require(*feature); + } + } if self .body() .get("tools") @@ -837,6 +844,31 @@ pub enum Operation { } impl Operation { + pub(crate) fn with_middleware_requirements( + self, + original: CapabilityRequirements, + inherited: CapabilityRequirements, + ) -> Result { + let Self::Generate(mut request) = self else { + return Err(OperationError::EmptyField { + field: "middleware capabilities", + }); + }; + Arc::make_mut(&mut request.payload).middleware_requirements = Some((original, inherited)); + Ok(Self::Generate(request)) + } + + /// 请求转换前的需求仅供诊断,选路始终使用有效上游需求。 + #[must_use] + pub fn original_capability_requirements(&self) -> CapabilityRequirements { + if let Self::Generate(request) = self + && let Some((original, _)) = &request.payload.middleware_requirements + { + return original.clone(); + } + self.capability_requirements() + } + /// 把协议正文编码为中间件可见的原始字节;不包含非 wire context 或会话状态。 /// /// # Errors diff --git a/backend/crates/gateway-core/src/routing/catalog.rs b/backend/crates/gateway-core/src/routing/catalog.rs index 524055025..4703a126c 100644 --- a/backend/crates/gateway-core/src/routing/catalog.rs +++ b/backend/crates/gateway-core/src/routing/catalog.rs @@ -11,6 +11,15 @@ use super::{ ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId, }; +/// 扩展集合持有的直接模型别名;执行与元数据仍归属内置 Provider。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContributedModelAlias { + pub owner: String, + pub id: PublicModelId, + pub provider: ProviderKind, + pub target: UpstreamModelId, +} + /// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderModelDescriptor { diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 8325c6a53..1453ed494 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -9,9 +9,9 @@ pub use crate::account::scope::{ }; pub use crate::identity::ProviderKind; pub use catalog::{ - ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable, - ProviderModelCapabilities, ProviderModelContent, ProviderModelDescriptor, - PublicModelDescriptor, + ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort, + ProviderCatalogUnavailable, ProviderModelCapabilities, ProviderModelContent, + ProviderModelDescriptor, PublicModelDescriptor, }; pub use snapshot::RuntimeSnapshot; diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index b7abd23a8..456e29775 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -303,6 +303,8 @@ pub enum RuntimeSnapshotCompileError { RevisionChanged, #[error("runtime snapshot contains invalid frozen data")] InvalidData, + #[error("extension model aliases conflict with the provider catalog or model mappings")] + InvalidExtensionModels, #[error("provider model catalog changed while the snapshot was compiling")] CatalogChanged, } @@ -420,13 +422,15 @@ impl RuntimeSnapshotCompiler { { return Err(RuntimeSnapshotCompileError::RevisionChanged); } - return Ok(snapshot + let snapshot = snapshot .with_provider_catalog_generations(if cached.is_some() { BTreeMap::new() } else { observed_generations }) - .with_extensions(extensions)); + .with_extensions(extensions); + snapshot.validate_extension_models()?; + return Ok(snapshot); } } Err(RuntimeSnapshotCompileError::CatalogChanged) @@ -701,6 +705,47 @@ impl RuntimeSnapshot { pub const fn extensions(&self) -> Option<&crate::runtime::extensions::ExtensionSetReference> { self.extensions.as_ref() } + + fn model_aliases(&self) -> &[super::ContributedModelAlias] { + self.extensions + .as_ref() + .map_or(&[], |set| set.model_aliases()) + } + + fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> { + self.model_aliases() + .iter() + .find(|alias| alias.id.as_str() == model) + } + + fn validate_extension_models(&self) -> Result<(), RuntimeSnapshotCompileError> { + let mut ids = BTreeSet::new(); + for alias in self.model_aliases() { + let valid = ids.insert(&alias.id) + && self.providers.contains(&alias.provider) + && !self.model_mappings.contains_key(alias.id.as_str()) + && !self.model_mappings.contains_key(alias.target.as_str()) + && !self + .model_mappings + .values() + .any(|target| target == alias.id.as_str()) + && self.model_alias(alias.target.as_str()).is_none() + && !self.provider_models.values().any(|models| { + models + .keys() + .any(|model| model.as_str() == alias.id.as_str()) + }) + && self + .provider_models + .get(&alias.provider) + .is_some_and(|models| models.contains_key(&alias.target)); + if !valid { + tracing::warn!(owner = %alias.owner, model = %alias.id, "插件模型别名与宿主目录或映射冲突,拒绝发布候选快照"); + return Err(RuntimeSnapshotCompileError::InvalidExtensionModels); + } + } + Ok(()) + } #[must_use] pub fn with_pricing(mut self, pricing: Arc) -> Self { self.pricing = pricing; @@ -901,6 +946,12 @@ impl RuntimeSnapshot { .keys() .filter_map(|model| PublicModelId::new(model.clone()).ok()), ); + models.extend( + self.model_aliases() + .iter() + .filter(|alias| &alias.provider == provider) + .map(|alias| alias.id.clone()), + ); models.into_iter().collect() } @@ -930,6 +981,15 @@ impl RuntimeSnapshot { profiles.insert(public_model, presentation.clone()); } } + for alias in self + .model_aliases() + .iter() + .filter(|alias| &alias.provider == provider) + { + if let Some(presentation) = presentations.get(&alias.target) { + profiles.insert(alias.id.clone(), presentation.clone()); + } + } profiles .into_iter() .map(|(model, presentation)| super::PublicModelProfile::new(model, presentation)) @@ -999,6 +1059,12 @@ impl RuntimeSnapshot { if !self.providers.contains(provider) { return false; } + if self + .model_alias(public_model.as_str()) + .is_some_and(|alias| &alias.provider != provider) + { + return false; + } if !self.exhaustive_provider_catalogs.contains(provider) { return true; } @@ -1022,6 +1088,9 @@ impl RuntimeSnapshot { #[must_use] pub fn mapped_model(&self, requested: &str) -> String { + if let Some(alias) = self.model_alias(requested) { + return alias.target.as_str().to_owned(); + } let original = requested; let mut current = original.to_owned(); let mut seen = BTreeSet::new(); @@ -1122,6 +1191,12 @@ impl RuntimeSnapshot { if !self.providers.contains(provider) { continue; } + if self + .model_alias(public_model.as_str()) + .is_some_and(|alias| &alias.provider != provider) + { + continue; + } if context .required_provider .as_ref() @@ -1132,7 +1207,9 @@ 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.model_mappings.contains_key(requested_model) + || self.model_alias(requested_model).is_some() + { UpstreamModelId::new(mapped_model) } else { UpstreamModelId::from_client_wire(mapped_model) diff --git a/backend/crates/gateway-core/src/runtime/extensions.rs b/backend/crates/gateway-core/src/runtime/extensions.rs index c44357dd9..7875b5f1d 100644 --- a/backend/crates/gateway-core/src/runtime/extensions.rs +++ b/backend/crates/gateway-core/src/runtime/extensions.rs @@ -1,10 +1,10 @@ -//! 可发布能力集合的中立身份与保活合同;不解释包格式、进程或管理配置。 +//! 可发布能力集合的中立身份、静态目录与保活合同;不解释包格式、进程或管理配置。 use std::{fmt, sync::Arc}; use futures::future::BoxFuture; -use crate::routing::ConfigRevision; +use crate::routing::{ConfigRevision, ContributedModelAlias}; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ExtensionSetId(String); @@ -32,6 +32,11 @@ impl ExtensionSetId { pub trait ExtensionSetLease: Send + Sync { fn is_ready(&self) -> bool; + /// 与集合一起冻结的目录事实,读取不调用插件进程。 + fn model_aliases(&self) -> &[ContributedModelAlias] { + &[] + } + /// 局部故障已有请求级处理计划时,集合仍可提供服务。 fn can_serve(&self) -> bool { self.is_ready() @@ -61,6 +66,11 @@ impl ExtensionSetReference { pub fn can_serve(&self) -> bool { self.lease.can_serve() } + + #[must_use] + pub fn model_aliases(&self) -> &[ContributedModelAlias] { + self.lease.model_aliases() + } } impl fmt::Debug for ExtensionSetReference { diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 06618f38d..8f69aea36 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -723,6 +723,162 @@ struct FixedRoutingPolicy { inputs: Mutex>, } +#[derive(Debug)] +struct RetryTestPolicy { + decision: gateway_core::engine::policy::RetryDecision, + facts: Arc>>, +} + +impl RequestPolicyPlan for RetryTestPolicy { + fn route_model( + &self, + _: ModelRouteInput, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Ok(ModelRouteDecision::Unhandled) }) + } + fn schedule_account( + &self, + _: AccountScheduleInput, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Ok(AccountScheduleDecision::Delegate) }) + } + fn retry_decision( + &self, + input: gateway_core::engine::policy::RetryInput, + ) -> BoxFuture<'static, Result> + { + self.facts.lock().unwrap().push(input.facts); + let decision = self.decision; + Box::pin(async move { Ok(decision) }) + } +} + +struct RetryTestProvider { + error: ProviderError, + calls: AtomicUsize, +} + +#[async_trait] +impl Provider for RetryTestProvider { + fn name(&self) -> &'static str { + "openai" + } + fn catalog_generation(&self) -> ProviderCatalogGeneration { + ProviderCatalogGeneration::default() + } + async fn query_model_capabilities( + &self, + ) -> Result, ProviderError> { + Ok(vec![]) + } + async fn execute( + self: Arc, + request: ProviderRequest, + _: AttemptContext, + ) -> Result { + if self.calls.fetch_add(1, Ordering::SeqCst) > 0 { + return Err(ProviderError::new( + ProviderErrorKind::NoEligibleAccount, + UpstreamSendState::NotSent, + )); + } + let candidate = request.candidate(); + let metadata = ProviderCallMetadata::new( + candidate.provider().clone(), + candidate.upstream_model().cloned().unwrap(), + ProviderAccountId::new("acct_openai").unwrap(), + UpstreamTransport::new("http").unwrap(), + ); + Ok(ProviderStream::new( + metadata, + futures::stream::iter([Err(self.error.clone())]), + (), + )) + } +} + +#[test] +fn retry_policy_can_stop_but_cannot_bypass_replay_safety() { + use gateway_core::engine::policy::RetryDecision; + for (decision, send_state, replay_safe, expected_allowed, expected_calls) in [ + ( + RetryDecision::Stop, + UpstreamSendState::NotSent, + true, + true, + 1, + ), + ( + RetryDecision::Retry, + UpstreamSendState::NotSent, + true, + true, + 2, + ), + ( + RetryDecision::Retry, + UpstreamSendState::Ambiguous, + true, + false, + 1, + ), + ( + RetryDecision::Retry, + UpstreamSendState::Sent, + false, + false, + 1, + ), + ] { + block_on(async { + let error = ProviderError::new(ProviderErrorKind::Transport, send_state); + let error = if replay_safe { + error.with_replay_safe() + } else { + error + }; + let provider = Arc::new(RetryTestProvider { + error, + calls: AtomicUsize::new(0), + }); + let facts = Arc::new(Mutex::new(vec![])); + let generation = super::extensions::reference("retry-policy"); + let policies = RequestPolicyExtensionIndex::default(); + let _owner = policies + .register( + generation.id().clone(), + Arc::new(RetryTestPolicy { + decision, + facts: facts.clone(), + }), + ) + .unwrap(); + let service = DefaultExecutionService::new( + RuntimeSnapshotHandle::new(request_policy_snapshot( + generation, + &[ProviderKind::new("openai").unwrap()], + )), + Arc::new(TrackingExecutionStore::default()), + ProviderRegistry::new([provider.clone() as Arc]).unwrap(), + Arc::new(Admissions::default()), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + ) + .with_request_policies(policies); + let mut started = service + .start(request(&service, ClientTransport::HttpJson)) + .await + .unwrap(); + assert!(started.session.collect_uncommitted().await.is_err()); + assert_eq!(provider.calls.load(Ordering::SeqCst), expected_calls); + let observed = facts.lock().unwrap(); + assert_eq!(observed[0].retry_allowed, expected_allowed); + assert_eq!(observed[0].send_state, send_state); + assert_eq!(observed[0].remaining_routing_attempts, 31); + }); + } +} + impl RequestPolicyPlan for FixedRoutingPolicy { fn route_model( &self, @@ -2957,11 +3113,34 @@ impl Provider for NativeCatalogProvider { #[test] fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() { + #[derive(Debug)] + struct Aliases(Vec); + impl gateway_core::runtime::extensions::ExtensionSetLease for Aliases { + fn is_ready(&self) -> bool { + true + } + fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] { + &self.0 + } + } for fail in [false, true] { - let snapshot = start_snapshot().with_model_mappings(BTreeMap::from([ - ("alias".to_owned(), "gpt-start".to_owned()), - ("missing-alias".to_owned(), "unavailable-model".to_owned()), - ])); + let snapshot = start_snapshot() + .with_model_mappings(BTreeMap::from([ + ("alias".to_owned(), "gpt-start".to_owned()), + ("missing-alias".to_owned(), "unavailable-model".to_owned()), + ])) + .with_extensions(Some(ExtensionSetReference::new( + gateway_core::runtime::extensions::ExtensionSetId::new("catalog-aliases".into()) + .unwrap(), + Arc::new(Aliases(vec![ + gateway_core::routing::ContributedModelAlias { + owner: "catalog-plugin".into(), + id: PublicModelId::new("plugin-alias").unwrap(), + provider: ProviderKind::new("openai").unwrap(), + target: UpstreamModelId::new("gpt-start").unwrap(), + }, + ])), + ))); let service = DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot), Arc::new(TrackingExecutionStore::default()), @@ -2993,9 +3172,13 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models( .collect::>(); assert_eq!( pairs.iter().map(|(id, _)| *id).collect::>(), - ["gpt-start", "alias"] + ["gpt-start", "alias", "plugin-alias"] ); assert_eq!(pairs[0].1, pairs[1].1, "Core preserves alias payload bytes"); + assert_eq!( + pairs[0].1, pairs[2].1, + "插件别名使用相同原生对象,不伪造字段" + ); } } diff --git a/backend/crates/gateway-core/tests/engine/middleware.rs b/backend/crates/gateway-core/tests/engine/middleware.rs index 6cd9b9377..10f442a33 100644 --- a/backend/crates/gateway-core/tests/engine/middleware.rs +++ b/backend/crates/gateway-core/tests/engine/middleware.rs @@ -169,6 +169,138 @@ fn operation() -> Operation { )) } +fn body_operation(body: &Value) -> Operation { + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap(), + )) +} + +#[test] +fn capability_declaration_preserves_original_semantics_and_recomputes_upstream_needs() { + use gateway_core::{engine::middleware::MiddlewareCapabilityDeclaration, operation::Feature}; + let original = json!({"model":"gpt-test","input":"answer", "tools":[{"type":"function"}], + "text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}); + let mut converted = original.clone(); + converted.as_object_mut().unwrap().remove("text"); + converted["input"] = json!( + "Return an object matching the supplied schema; the middleware validates the response." + ); + let request = MiddlewareRequest::new( + "openai", + vec![], + serde_json::to_vec(&original).unwrap().into(), + ) + .replace_parts( + "openai".into(), + vec![], + serde_json::to_vec(&converted).unwrap().into(), + Some(MiddlewareCapabilityDeclaration { + handled: [Feature::JsonSchema].into(), + required: [Feature::Reasoning].into(), + }), + ) + .unwrap(); + let operation = request + .apply_capabilities(body_operation(&converted)) + .unwrap(); + assert_eq!( + operation.original_capability_requirements(), + body_operation(&original).capability_requirements() + ); + assert_eq!( + operation.capability_requirements().features(), + &[Feature::Tools, Feature::Reasoning].into() + ); + assert_eq!( + request.body().as_ref(), + serde_json::to_vec(&converted).unwrap() + ); + + // 后续普通正文修改也不能删掉未承担的原始语义,或藏起实际新增的需求。 + converted.as_object_mut().unwrap().remove("tools"); + converted["input"] = json!([{"type":"input_image","image_url":"synthetic"}]); + let request = request + .replace_parts( + "openai".into(), + vec![], + serde_json::to_vec(&converted).unwrap().into(), + None, + ) + .unwrap(); + let operation = request + .apply_capabilities(body_operation(&converted)) + .unwrap(); + assert_eq!( + operation.capability_requirements().features(), + &[Feature::Tools, Feature::Reasoning, Feature::Vision].into() + ); + let changed = operation + .replace_middleware_wire( + "openai", + serde_json::to_vec(&json!({"input":"answer","text":{"format":{"type":"json_schema"}}})) + .unwrap() + .into(), + ) + .unwrap(); + assert!( + changed + .capability_requirements() + .features() + .contains(&Feature::JsonSchema) + ); +} + +#[test] +fn capability_declaration_cannot_erase_remaining_or_invented_features_or_native_continuation() { + use gateway_core::{engine::middleware::MiddlewareCapabilityDeclaration, operation::Feature}; + let source = json!({"tools":[{"type":"function"}],"previous_response_id":"resp-original"}); + let request = MiddlewareRequest::new( + "openai", + vec![], + serde_json::to_vec(&source).unwrap().into(), + ); + for (handled, body) in [ + (Feature::Tools, source), + (Feature::JsonSchema, json!({"input":"removed"})), + (Feature::NativeContinuation, json!({"input":"removed"})), + ] { + assert!( + request + .clone() + .replace_parts( + "openai".into(), + vec![], + serde_json::to_vec(&body).unwrap().into(), + Some(MiddlewareCapabilityDeclaration { + handled: [handled].into(), + required: Default::default() + }) + ) + .is_err() + ); + } + let converted = json!({"input":"tools converted"}); + let request = request + .replace_parts( + "openai".into(), + vec![], + serde_json::to_vec(&converted).unwrap().into(), + Some(MiddlewareCapabilityDeclaration { + handled: [Feature::Tools].into(), + required: Default::default(), + }), + ) + .unwrap(); + assert!( + request + .apply_capabilities(body_operation(&converted)) + .unwrap() + .capability_requirements() + .features() + .contains(&Feature::NativeContinuation) + ); +} + #[test] fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() { futures::executor::block_on(async { diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 6291769e0..0e1cf9568 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -13,9 +13,9 @@ use gateway_core::routing::snapshot::{ SnapshotProviderAccountFacts, SnapshotSettingsFacts, SnapshotStoreError, SnapshotStorePort, }; use gateway_core::routing::{ - ConfigRevision, ModelCapabilities, ModelPresentation, ProviderCatalogGeneration, - ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind, ProviderModelCapabilities, - PublicModelId, UpstreamModelId, + ConfigRevision, ContributedModelAlias, ModelCapabilities, ModelPresentation, + ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable, ProviderKind, + ProviderModelCapabilities, PublicModelId, UpstreamModelId, }; use gateway_core::runtime::extensions::{ ExtensionPreparationError, ExtensionPreparationPort, ExtensionSetId, ExtensionSetLease, @@ -93,6 +93,134 @@ struct CountingExtensionPreparation { drops: Arc, } +struct AliasLease(Vec); + +impl ExtensionSetLease for AliasLease { + fn is_ready(&self) -> bool { + true + } + fn model_aliases(&self) -> &[ContributedModelAlias] { + &self.0 + } +} + +struct AliasPreparation(ExtensionSetReference); + +impl ExtensionPreparationPort for AliasPreparation { + fn prepare( + &self, + _: ConfigRevision, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(self.0.clone()) }) + } +} + +fn alias(id: &str, target: &str) -> ContributedModelAlias { + ContributedModelAlias { + owner: "test-plugin".into(), + id: PublicModelId::new(id).unwrap(), + provider: ProviderKind::new("alpha").unwrap(), + target: UpstreamModelId::new(target).unwrap(), + } +} + +fn compile_aliases( + aliases: Vec, +) -> Result { + let facts = SnapshotFacts::new( + revision(1), + revision(1), + SnapshotSettingsFacts::new( + 3, + 50, + "smart", + BTreeMap::from([("configured".into(), "upstream-model".into())]), + None, + None, + ), + vec![], + vec![], + vec![SnapshotProviderAccountFacts::new( + gateway_core::account::ProviderAccountId::new("acct_alias").unwrap(), + "alpha", + )], + vec![], + ); + block_on( + RuntimeSnapshotCompiler::new( + Arc::new(TestSnapshotStore::new(Ok(facts))), + Arc::new(PublishingCatalog { + generation: AtomicU64::new(1), + queries: AtomicUsize::new(1), + }), + ) + .with_extensions(Arc::new(AliasPreparation(ExtensionSetReference::new( + ExtensionSetId::new("alias-generation".into()).unwrap(), + Arc::new(AliasLease(aliases)), + )))) + .compile(), + ) +} + +#[test] +fn contributed_alias_uses_the_same_target_for_listing_profiles_and_routing() { + let snapshot = compile_aliases(vec![alias("plugin-model", "upstream-model")]).unwrap(); + let model = PublicModelId::new("plugin-model").unwrap(); + let provider = ProviderKind::new("alpha").unwrap(); + assert!( + snapshot + .public_models_for_scope(&snapshot.all_account_scope()) + .contains(&model) + ); + assert!(snapshot.contains_public_model_for_scope(&model, &snapshot.all_account_scope())); + let profiles = snapshot.public_model_profiles_for_provider(&provider); + let alias_profile = profiles + .iter() + .find(|profile| profile.model() == &model) + .unwrap(); + let native_profile = profiles + .iter() + .find(|profile| profile.model().as_str() == "upstream-model") + .unwrap(); + assert_eq!(alias_profile.presentation(), native_profile.presentation()); + let plan = snapshot + .plan( + &model, + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .unwrap(); + assert_eq!(plan.candidates()[0].provider(), &provider); + assert_eq!( + plan.candidates()[0].upstream_model().unwrap().as_str(), + "upstream-model" + ); +} + +#[test] +fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_publication() { + for aliases in [ + vec![alias("upstream-model", "upstream-model")], + vec![alias("configured", "upstream-model")], + vec![alias("plugin-model", "configured")], + vec![alias("plugin-model", "missing")], + vec![ + alias("plugin-model", "other"), + alias("other", "plugin-model"), + ], + vec![ + alias("plugin-model", "upstream-model"), + alias("plugin-model", "upstream-model"), + ], + ] { + assert_eq!( + compile_aliases(aliases).unwrap_err(), + RuntimeSnapshotCompileError::InvalidExtensionModels + ); + } +} + impl ExtensionPreparationPort for CountingExtensionPreparation { fn prepare( &self, diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index 4a414ea9a..30ce53997 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -6,7 +6,9 @@ { "capability": "frontend_authentication", "versions": [1] }, { "capability": "scheduler", "versions": [1] }, { "capability": "model_router", "versions": [1] }, - { "capability": "middleware", "versions": [1] }, + { "capability": "model_catalog", "versions": [1] }, + { "capability": "retry_policy", "versions": [1] }, + { "capability": "middleware", "versions": [1, 2] }, { "capability": "request_lifecycle", "versions": [1] }, { "capability": "web_socket_observer", "versions": [1] }, { "capability": "usage", "versions": [1] }, @@ -17,6 +19,7 @@ "network", "models", "accounts", + "data", "requests", "public_endpoints" ] diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs b/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs new file mode 100644 index 000000000..0fccccd92 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs @@ -0,0 +1,78 @@ +use std::{collections::BTreeSet, time::Duration}; + +use gateway_admin::model::{ + AdminError, + plugins::instances::{PluginCapabilityBinding, PluginInstance}, +}; +use gateway_core::routing::{ContributedModelAlias, ProviderKind, PublicModelId, UpstreamModelId}; +use gateway_plugin_sdk::{Capability, Manifest, Stage, call::catalog::ModelCatalogRegistration}; + +use crate::RpcSession; + +pub(crate) async fn prepare( + manifest: &Manifest, + instance: &PluginInstance, + session: &RpcSession, +) -> Result, AdminError> { + if !manifest.contributes.contains_key(&Capability::ModelCatalog) { + return Ok(Vec::new()); + } + validate_bindings(manifest, &instance.bindings)?; + let reply = session + .call( + "model_catalog.register", + session.context(Stage::Registration, Duration::from_secs(5)), + serde_json::json!({}), + Vec::new(), + ) + .await + .map_err(|_| AdminError::invalid("插件模型目录注册失败"))?; + if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 { + return Err(AdminError::invalid("插件模型目录信封无效或过大")); + } + let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload) + .map_err(|_| AdminError::invalid("插件模型目录声明无效"))?; + if registration.models.len() > 256 { + return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名")); + } + let mut ids = BTreeSet::new(); + registration + .models + .into_iter() + .map(|model| { + if !matches!(model.provider.as_str(), "openai" | "xai") || model.id == model.model { + return Err(AdminError::invalid( + "模型别名必须指向内置 Provider 的不同上游模型", + )); + } + let id = PublicModelId::new(model.id) + .map_err(|_| AdminError::invalid("模型别名 ID 无效"))?; + if !ids.insert(id.clone()) { + return Err(AdminError::invalid(format!("插件模型别名重复:{id}"))); + } + Ok(ContributedModelAlias { + owner: instance.id.clone(), + id, + provider: ProviderKind::new(model.provider) + .map_err(|_| AdminError::invalid("模型别名 Provider 无效"))?, + target: UpstreamModelId::new(model.model) + .map_err(|_| AdminError::invalid("模型别名目标无效"))?, + }) + }) + .collect() +} + +pub(crate) fn validate_bindings( + manifest: &Manifest, + bindings: &[PluginCapabilityBinding], +) -> Result<(), AdminError> { + // 目录随实例完整发布;不接受请求级绑定,以免展示与执行的可见范围分叉。 + if let Some(declaration) = manifest.contributes.get(&Capability::ModelCatalog) + && bindings + .iter() + .any(|binding| binding.contribution == declaration.id) + { + return Err(AdminError::invalid("模型目录随实例发布,不支持请求级绑定")); + } + Ok(()) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs index 03a69e8dd..8335b556f 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs @@ -1,3 +1,4 @@ +pub(super) mod catalog; pub(super) mod command_line; pub(super) mod frontend_authentication; pub(super) mod management; 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 54825c030..c07b980ad 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/middleware.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/middleware.rs @@ -132,7 +132,13 @@ 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); + let invocation = MiddlewareInvocation::new( + &context, + request, + next, + entry.requests_authorized, + entry.capability_version, + ); let (protocol, headers, body_visible, payload) = match invocation.request_projection() { Ok(projection) => projection, Err(_) => return recover_invalid(entry, &invocation).await, 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 3cfb54ccd..be595a3c1 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/mod.rs @@ -1,4 +1,5 @@ mod middleware; +mod retry; mod route_schedule; use std::{fmt, sync::Arc, time::Duration}; @@ -17,6 +18,7 @@ const POLICY_TIMEOUT: Duration = Duration::from_secs(2); pub(crate) enum PolicyEntry { Router(ModelRouterEntry), Scheduler(AccountSchedulerEntry), + Retry(RetryEntry), Middleware(MiddlewareEntry), } @@ -26,6 +28,14 @@ struct PolicyInvocation { callbacks: Arc, } +pub(crate) struct RetryEntry { + order: i32, + plugin_id: String, + instance_id: String, + invocation: Option, + scope: BindingScope, +} + pub(crate) struct ModelRouterEntry { order: i32, plugin_id: String, @@ -46,6 +56,7 @@ pub(crate) struct AccountSchedulerEntry { } pub(crate) struct MiddlewareEntry { + capability_version: u32, order: i32, plugin_id: String, instance_id: String, @@ -62,12 +73,16 @@ pub(crate) fn validate_bindings( ) -> Result<(), AdminError> { let mut has_router = false; let mut has_scheduler = false; + let mut has_retry = false; let mut middleware_stages = std::collections::BTreeSet::new(); for binding in bindings { let capability = crate::contribution::resolve(manifest, binding)?.capability; if !matches!( capability, - Capability::ModelRouter | Capability::Scheduler | Capability::Middleware + Capability::ModelRouter + | Capability::Scheduler + | Capability::Middleware + | Capability::RetryPolicy ) { continue; } @@ -80,6 +95,16 @@ pub(crate) fn validate_bindings( return Err(AdminError::invalid("数据面绑定的故障策略无效")); } match capability { + Capability::RetryPolicy => { + if std::mem::replace(&mut has_retry, true) + || stage != Stage::Retry + || binding.failure_policy != PluginFailurePolicy::Delegate + { + return Err(AdminError::invalid( + "重试策略只允许一次 retry 绑定,故障策略必须为 delegate", + )); + } + } Capability::ModelRouter => { if std::mem::replace(&mut has_router, true) || stage != Stage::Routing { return Err(AdminError::invalid( @@ -140,7 +165,10 @@ pub(crate) fn compile_entries( }; matches!( capability, - Capability::ModelRouter | Capability::Scheduler | Capability::Middleware + Capability::ModelRouter + | Capability::Scheduler + | Capability::Middleware + | Capability::RetryPolicy ) .then(|| { compile_entry( @@ -148,6 +176,7 @@ pub(crate) fn compile_entries( instance_id, binding, capability, + manifest.contributes[&capability].version, Some(invocation.clone()), permissions.contains(&Permission::Requests), ) @@ -167,6 +196,7 @@ pub(crate) fn unavailable_entries( let capability = match binding.stage.as_str() { "routing" => Capability::ModelRouter, "scheduling" => Capability::Scheduler, + "retry" => Capability::RetryPolicy, "request" | "attempt" => Capability::Middleware, _ => return None, }; @@ -178,6 +208,7 @@ pub(crate) fn unavailable_entries( &instance.id, binding, capability, + 1, None, false, )) @@ -190,11 +221,19 @@ 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 { + Capability::RetryPolicy => PolicyEntry::Retry(RetryEntry { + order: binding.order, + plugin_id: plugin_id.to_owned(), + instance_id: instance_id.to_owned(), + invocation, + scope, + }), Capability::ModelRouter => PolicyEntry::Router(ModelRouterEntry { order: binding.order, plugin_id: plugin_id.to_owned(), @@ -213,6 +252,7 @@ fn compile_entry( requests_authorized, }), Capability::Middleware => PolicyEntry::Middleware(MiddlewareEntry { + capability_version, order: binding.order, plugin_id: plugin_id.to_owned(), instance_id: instance_id.to_owned(), @@ -233,6 +273,7 @@ fn compile_entry( pub(crate) struct PluginRequestPolicyPlan { routers: Arc<[ModelRouterEntry]>, schedulers: Arc<[AccountSchedulerEntry]>, + retries: Arc<[RetryEntry]>, middleware: Arc<[MiddlewareEntry]>, policy_timeout: Duration, middleware_timeout: Duration, @@ -244,6 +285,7 @@ impl fmt::Debug for PluginRequestPolicyPlan { .debug_struct("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()) .finish_non_exhaustive() } @@ -256,15 +298,21 @@ impl PluginRequestPolicyPlan { ) -> Result>, AdminError> { let mut routers = Vec::new(); let mut schedulers = Vec::new(); + let mut retries = Vec::new(); let mut middleware = Vec::new(); for entry in entries { match entry { PolicyEntry::Router(entry) => routers.push(entry), PolicyEntry::Scheduler(entry) => schedulers.push(entry), + PolicyEntry::Retry(entry) => retries.push(entry), PolicyEntry::Middleware(entry) => middleware.push(entry), } } - if routers.is_empty() && schedulers.is_empty() && middleware.is_empty() { + if routers.is_empty() + && schedulers.is_empty() + && middleware.is_empty() + && retries.is_empty() + { return Ok(None); } routers.sort_by(|left, right| { @@ -274,6 +322,13 @@ impl PluginRequestPolicyPlan { &right.instance_id, )) }); + retries.sort_by(|left, right| { + (left.order, &left.plugin_id, &left.instance_id).cmp(&( + right.order, + &right.plugin_id, + &right.instance_id, + )) + }); for (index, left) in schedulers.iter().enumerate() { if schedulers[index + 1..] .iter() @@ -299,6 +354,7 @@ impl PluginRequestPolicyPlan { Ok(Some(Arc::new(Self { routers: routers.into(), schedulers: schedulers.into(), + retries: retries.into(), middleware: middleware.into(), policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT), middleware_timeout: maximum_call_timeout, @@ -307,7 +363,7 @@ impl PluginRequestPolicyPlan { #[must_use] pub(crate) fn has_request_policy(&self) -> bool { - !self.routers.is_empty() || !self.schedulers.is_empty() + !self.routers.is_empty() || !self.schedulers.is_empty() || !self.retries.is_empty() } #[must_use] diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs new file mode 100644 index 000000000..d204916ff --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs @@ -0,0 +1,107 @@ +use std::{sync::Arc, time::Duration}; + +use futures::future::BoxFuture; +use gateway_core::{ + engine::policy::{RequestPolicyFault, RetryDecision, RetryInput}, + upstream::UpstreamSendState, +}; +use gateway_plugin_sdk::{ + SendState, Stage, + call::policy::{RetryAction, RetryDecision as WireDecision, RetryDecisionRequest}, +}; + +use super::RetryEntry; + +pub(super) fn decide( + entries: Arc<[RetryEntry]>, + input: RetryInput, + timeout: Duration, +) -> BoxFuture<'static, Result> { + Box::pin(async move { + let started = std::time::Instant::now(); + for entry in entries.iter().filter(|entry| { + !input.extension_scope.contains(&entry.instance_id) + && entry.scope.matches_processing( + &input.client_key_id, + &input.account_group_ids, + Some(&input.facts.provider), + input.facts.model.as_deref(), + ) + }) { + // 整条委托链共享预算,不能每个插件重新取得完整等待时间。 + let remaining = timeout + .min(input.facts.remaining_deadline) + .saturating_sub(started.elapsed()); + if remaining.is_zero() { + break; + } + match call(entry, &input, remaining).await { + Ok(RetryDecision::Delegate) => {} + Ok(decision) => return Ok(decision), + Err(()) => tracing::warn!( + plugin_id = entry.plugin_id, + instance_id = entry.instance_id, + request_id = input.request_id.as_str(), + "插件重试策略无效或调用失败,委托后续策略" + ), + } + } + Ok(RetryDecision::Delegate) + }) +} + +async fn call( + entry: &RetryEntry, + input: &RetryInput, + timeout: Duration, +) -> Result { + let invocation = entry.invocation.as_ref().ok_or(())?; + let mut allowed_actions = vec![RetryAction::Stop]; + if input.facts.retry_allowed { + allowed_actions.push(RetryAction::Retry); + } + let request = RetryDecisionRequest { + request_id: input.request_id.as_str().to_owned(), + attempt_index: input.facts.attempt_index.get(), + provider: input.facts.provider.as_str().to_owned(), + model: input.facts.model.clone(), + error_kind: input.facts.error_kind.as_str().to_owned(), + upstream_status: input.facts.upstream_status, + send_state: match input.facts.send_state { + UpstreamSendState::NotSent => SendState::NotSent, + UpstreamSendState::Sent => SendState::Sent, + UpstreamSendState::Ambiguous => SendState::Ambiguous, + }, + remaining_routing_attempts: input.facts.remaining_routing_attempts, + remaining_deadline_ms: u64::try_from(input.facts.remaining_deadline.as_millis()) + .unwrap_or(u64::MAX), + allowed_actions, + }; + let mut context = invocation.session.context(Stage::Retry, timeout); + context.request_id = Some(request.request_id.clone()); + context.attempt_id = Some(format!("{}:{}", request.request_id, request.attempt_index)); + let reply = invocation + .session + .call( + "policy.retry_decision", + context, + serde_json::to_value(request).map_err(|_| ())?, + vec![], + ) + .await + .map_err(|_| ())?; + if !reply.payload.is_empty() + || reply + .result + .as_object() + .is_none_or(|fields| fields.len() != 1) + { + return Err(()); + } + match serde_json::from_value::(reply.result).map_err(|_| ())? { + WireDecision::Delegate => Ok(RetryDecision::Delegate), + WireDecision::Stop => Ok(RetryDecision::Stop), + WireDecision::Retry if input.facts.retry_allowed => Ok(RetryDecision::Retry), + WireDecision::Retry => Err(()), + } +} 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 123449cec..0c04ed171 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 @@ -31,6 +31,14 @@ const MAX_HEADER_TOTAL_BYTES: usize = 64 * 1024; const OPAQUE_HEADERS_KEY: &str = "opaque_request_headers"; impl RequestPolicyPlan for PluginRequestPolicyPlan { + fn retry_decision( + &self, + input: gateway_core::engine::policy::RetryInput, + ) -> BoxFuture<'static, Result> + { + super::retry::decide(self.retries.clone(), input, self.policy_timeout) + } + fn route_model( &self, input: ModelRouteInput, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/data.rs b/backend/crates/gateway-plugin/runtime/src/callback/data.rs new file mode 100644 index 000000000..aaae4ee47 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/data.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use gateway_admin::model::{ + PageSize, plugins::instances::PluginPermissionGrant, + provider_credentials::PluginAccountListQuery, +}; +use gateway_core::{account::ProviderAccountId, routing::ProviderKind}; +use gateway_plugin_sdk::{CallContext, PluginFault, Stage, call::data}; + +use super::{ + accounts::{PluginAccountPortSlot, map_admin_error}, + denied, invalid, +}; +use crate::RpcReply; + +pub(super) struct PluginData { + accounts: Arc, + authorized: bool, +} + +impl PluginData { + pub(super) fn new( + accounts: Arc, + grants: &[PluginPermissionGrant], + ) -> Self { + Self { + accounts, + authorized: grants.iter().any(|grant| grant.permission == "data"), + } + } + + pub(super) async fn call( + &self, + context: &CallContext, + method: &str, + params: serde_json::Value, + payload: &[u8], + ) -> Result { + // 管理范围的只读授权不继承到客户端请求链,避免借数据查询扩大当前 Key 的范围。 + if !self.authorized || !matches!(context.stage, Stage::Management | Stage::CommandLine) { + return Err(denied()); + } + if params != serde_json::json!({}) { + return Err(invalid()); + } + let accounts = self.accounts.upgrade().map_err(map_admin_error)?; + let payload = match method { + data::ACCOUNTS_LIST => { + let query: data::AccountFactsQuery = + serde_json::from_slice(payload).map_err(|_| invalid())?; + let page = accounts + .list(PluginAccountListQuery { + provider_kind: query + .provider_id + .map(ProviderKind::new) + .transpose() + .map_err(|_| invalid())?, + cursor: query + .cursor + .map(ProviderAccountId::new) + .transpose() + .map_err(|_| invalid())?, + limit: PageSize::new(query.limit).map_err(|_| invalid())?, + }) + .await + .map_err(map_admin_error)?; + serde_json::to_vec(&data::AccountFactsPage { + schema_version: 1, + accounts: page + .accounts + .into_iter() + .map(|account| data::AccountFacts { + account_id: account.id, + provider_id: account.provider_kind.as_str().to_owned(), + group_ids: account + .groups + .into_iter() + .map(|group| group.id.as_str().to_owned()) + .collect(), + enabled: account.enabled, + updated_at_ms: account.updated_at.timestamp_millis(), + }) + .collect(), + next_cursor: page.next_cursor.map(|id| id.as_str().to_owned()), + }) + } + data::QUOTA_GET => { + let query: data::QuotaFactsQuery = + serde_json::from_slice(payload).map_err(|_| invalid())?; + let account_id = ProviderAccountId::new(query.account_id).map_err(|_| invalid())?; + let quota = accounts + .get_quota(&account_id) + .await + .map_err(map_admin_error)?; + serde_json::to_vec(&data::QuotaFacts { + schema_version: 1, + account_id: account_id.as_str().to_owned(), + observed_at_ms: quota.observed_at.map(|time| time.timestamp_millis()), + windows: quota + .windows + .into_iter() + .map(|window| data::QuotaWindowFacts { + key: window.key, + window_seconds: window.window_seconds, + used_percent: window.used_percent, + reset_at_ms: window.reset_at.map(|time| time.timestamp_millis()), + }) + .collect(), + }) + } + _ => return Err(denied()), + } + .map_err(|_| invalid())?; + Ok(RpcReply { + result: serde_json::json!({}), + payload, + }) + } +} 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 61c59a678..f4d4fe319 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs @@ -28,8 +28,22 @@ use body::BodyResource; pub(crate) use body::MiddlewareBodyAuthority; use headers::{HeaderDirection, apply_header_mutations, project_headers, validate_headers}; +fn request_feature( + feature: gateway_plugin_sdk::call::middleware::RequestFeature, +) -> gateway_core::operation::Feature { + use gateway_core::operation::Feature; + use gateway_plugin_sdk::call::middleware::RequestFeature as Wire; + match feature { + Wire::Tools => Feature::Tools, + Wire::Vision => Feature::Vision, + Wire::Reasoning => Feature::Reasoning, + Wire::JsonSchema => Feature::JsonSchema, + } +} + pub(crate) struct MiddlewareInvocation { requests_authorized: bool, + capabilities_allowed: bool, transport: ClientTransport, state: Mutex, downstream_error: Arc>>, @@ -82,9 +96,13 @@ impl MiddlewareInvocation { request: MiddlewareRequest, next: Box, requests_authorized: bool, + capability_version: u32, ) -> Arc { Arc::new(Self { requests_authorized, + capabilities_allowed: capability_version >= 2 + && context.mount() == gateway_core::engine::middleware::MiddlewareMount::Request + && context.operation() == Some(gateway_core::operation::OperationKind::Generate), transport: context.transport(), state: Mutex::new(InvocationState { closed: false, @@ -258,7 +276,41 @@ impl MiddlewareInvocation { request: MiddlewareNextRequest, payload: Vec, ) -> Result { - let (mut protocol, mut headers, original_body) = original.into_parts(); + 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 request.body != MiddlewareRequestBody::Replace { + return Err(invalid()); + } + let handled = declaration + .handled + .iter() + .copied() + .map(request_feature) + .collect::>(); + let required = declaration + .required + .iter() + .copied() + .map(request_feature) + .collect::>(); + if handled.len() != declaration.handled.len() + || required.len() != declaration.required.len() + { + return Err(invalid()); + } + Ok( + gateway_core::engine::middleware::MiddlewareCapabilityDeclaration { + handled, + required, + }, + ) + }) + .transpose()?; if let Some(replacement) = request.protocol { if !self.requests_authorized { return Err(denied()); @@ -282,7 +334,9 @@ impl MiddlewareInvocation { MiddlewareRequestBody::Replace if self.requests_authorized => Bytes::from(payload), MiddlewareRequestBody::Replace => return Err(denied()), }; - Ok(MiddlewareRequest::new(protocol, headers, body)) + original + .replace_parts(protocol, headers, body, declaration) + .map_err(|_| invalid()) } async fn call_body_read( diff --git a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs index e41e45d5f..fa3795f58 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs @@ -1,5 +1,6 @@ mod accounts; mod affinity; +mod data; mod http; mod keys; mod log; @@ -62,6 +63,7 @@ impl PluginCallbackPorts { pub(crate) struct PluginCallbacks { accounts: Arc, + data: Arc, keys: Arc, models_authorized: bool, affinity: Arc, @@ -123,6 +125,7 @@ impl PluginCallbacks { ports: PluginCallbackPorts, ) -> Result { Ok(Self { + data: Arc::new(data::PluginData::new(ports.accounts.clone(), grants)), accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts, grants)), keys: ports.keys, models_authorized: grants.iter().any(|grant| grant.permission == "models"), @@ -371,6 +374,7 @@ impl CallbackHandler for PluginCallbacks { let log = self.log.clone(); let private_state = self.private_state.clone(); let accounts = self.accounts.clone(); + let data = self.data.clone(); let keys = self.keys.clone(); let models_authorized = self.models_authorized; let models = self.models.clone(); @@ -437,6 +441,13 @@ impl CallbackHandler for PluginCallbacks { let scope = Some(scope) .filter(|scope| scope.authorizes(&context)) .ok_or_else(denied)?; + if matches!( + method.as_str(), + gateway_plugin_sdk::call::data::ACCOUNTS_LIST + | gateway_plugin_sdk::call::data::QUOTA_GET + ) { + return data.call(&context, &method, params, &payload).await; + } if matches!( method.as_str(), "host.auth.list" | "host.auth.get" | "host.auth.get_runtime" | "host.auth.save" 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 d0f0a4246..fa7c20d98 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs @@ -233,6 +233,8 @@ impl PluginRuntime { session.clone(), callbacks.clone(), )?); + let model_aliases = + crate::adapter::catalog::prepare(&manifest, &instance, &session).await?; sessions.push(PreparedInstance { instance_id, artifact_sha256: instance.artifact_sha256, @@ -247,6 +249,7 @@ impl PluginRuntime { management, policy_entries, authentication_entries, + model_aliases, }) } } 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 b65f955a9..3bf43bec6 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs @@ -125,6 +125,7 @@ pub(super) struct PreparedSet { shutting_down: Arc, commands: Vec>, management: Vec, + model_aliases: Vec, } #[derive(Default)] @@ -136,6 +137,7 @@ struct PreparedContributions { policy_entries: Vec, authentication_entries: Vec, + model_aliases: Vec, } impl PreparedContributions { @@ -144,6 +146,7 @@ impl PreparedContributions { self.observer_entries.extend(other.observer_entries); self.commands.extend(other.commands); self.management.extend(other.management); + self.model_aliases.extend(other.model_aliases); self.policy_entries.extend(other.policy_entries); self.authentication_entries .extend(other.authentication_entries); @@ -171,6 +174,10 @@ impl ExtensionSetLease for PreparedSet { self.sessions_ready() && self.can_serve() } + fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] { + &self.model_aliases + } + fn can_serve(&self) -> bool { // 发布的是可用能力和故障绑定组成的完整计划;单个进程退出不能使原生转发失效。 !self.shutting_down.load(Ordering::Acquire) @@ -423,6 +430,24 @@ impl PluginRuntime { Ok(candidate) => contributions.append(candidate), Err(error) if required_revision == Some(instance.revision) => return Err(error), Err(error) => { + // 目录没有拒绝请求用的 binding,不能在恢复失败时悄悄撤销仍启用的别名。 + // 不复制其他候选的注册结果;让发布事务保留调用方持有的旧有效快照。 + let has_retained_catalog = self + .prepared + .lock() + .await + .values() + .filter_map(Weak::upgrade) + .any(|set| { + set.model_aliases + .iter() + .any(|alias| alias.owner == instance.id) + }); + if has_retained_catalog { + return Err(AdminError::unavailable( + "已启用的模型目录插件未就绪,请修复或停用后重试", + )); + } // 恢复失败只隔离该实例;绑定的拒绝策略仍保留,不能绕过认证或必需处理。 contributions .policy_entries @@ -443,7 +468,17 @@ impl PluginRuntime { management, policy_entries, authentication_entries, + model_aliases, } = contributions; + let mut alias_owners = BTreeMap::new(); + for alias in &model_aliases { + if let Some(previous) = alias_owners.insert(&alias.id, &alias.owner) { + return Err(AdminError::invalid(format!( + "模型别名 {} 在插件实例 {} 与 {} 之间冲突", + alias.id, previous, alias.owner + ))); + } + } let id = ExtensionSetId::new(uuid::Uuid::new_v4().to_string()) .map_err(|_| AdminError::internal("插件集合 ID 无效"))?; let observers = crate::adapter::observer::PluginObserverPlan::compile( @@ -501,6 +536,7 @@ impl PluginRuntime { commands, management, failures, + model_aliases, shutting_down: self.shutting_down.clone(), }); self.prepared @@ -824,6 +860,7 @@ impl PluginPreparation for PluginRuntime { 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)?; crate::adapter::frontend_authentication::validate_bindings( package.manifest(), &instance.bindings, diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs index cd0314541..4953fd853 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs @@ -208,6 +208,10 @@ fn callback_allowed(method: &str, stage: Stage, permissions: &[Permission]) -> b ) { return true; } + // 失败后的策略不得产生额外出站调用或读取账号凭据。 + if stage == Stage::Retry { + return false; + } if matches!( method, gateway_plugin_sdk::call::middleware::NEXT_METHOD @@ -231,6 +235,11 @@ fn callback_allowed(method: &str, stage: Stage, permissions: &[Permission]) -> b Permission::Accounts } "host.affinity.lookup" => Permission::Requests, + "host.data.accounts.list" | "host.data.quota.get" + if matches!(stage, Stage::Management | Stage::CommandLine) => + { + Permission::Data + } _ => return false, }; permissions.contains(&permission) diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/data.rs b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs new file mode 100644 index 000000000..797bc6df8 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/data.rs @@ -0,0 +1,213 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use gateway_admin::{ + model::{ + AdminError, MutationContext, Revision, + accounts::AccountRecord, + plugins::{instances::PluginPermissionGrant, management::PluginManagementRequest}, + provider_credentials::*, + }, + ports::{plugin_accounts::PluginAccountAccess, plugin_management::PluginManagement}, +}; +use gateway_core::{ + account::{AccountWeight, CredentialState, ProviderAccountId, QuotaState}, + routing::{ConfigRevision, ProviderKind}, + runtime::extensions::ExtensionPreparationPort, +}; +use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use serde_json::{Value, json}; + +#[derive(Default)] +struct Facts(AtomicUsize); + +fn account() -> AccountRecord { + let at = "2026-01-01T00:00:00Z".parse().unwrap(); + AccountRecord { + id: "acct_facts".into(), + provider_kind: ProviderKind::new("openai").unwrap(), + groups: vec![gateway_admin::model::account_groups::AccountGroupRef { + id: gateway_core::routing::AccountGroupId::new("grp_11111111111111111111111111111111") + .unwrap(), + name: "private group label".into(), + color: gateway_admin::model::account_groups::AccountGroupColor::parse("#112233FF") + .unwrap(), + enabled: true, + }], + name: "private name".into(), + notes: Some("private notes".into()), + email: Some("private@example.invalid".into()), + upstream_user_id: None, + upstream_account_id: None, + plan_type: Some("private plan".into()), + authentication_kind: "oauth".into(), + credential_revision: Revision::new(1).unwrap(), + has_refresh_token: true, + access_token_expires_at: None, + next_refresh_at: None, + enabled: true, + concurrency_limit: None, + weight: AccountWeight::new(1).unwrap(), + model_access: Default::default(), + outbound_proxy: None, + credential_state: CredentialState::Ready, + credential_observed_at: at, + quota: QuotaState::unknown(), + last_error_reason: None, + last_error_message: Some("private diagnostic".into()), + created_at: at, + updated_at: at, + } +} + +#[async_trait] +impl PluginAccountAccess for Facts { + async fn list(&self, query: PluginAccountListQuery) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + assert_eq!(query.limit.get(), 1); + assert_eq!(query.provider_kind.unwrap().as_str(), "openai"); + assert_eq!(query.cursor.unwrap().as_str(), "acct_before"); + Ok(PluginAccountPage { + accounts: vec![account()], + next_cursor: Some(ProviderAccountId::new("acct_facts").unwrap()), + }) + } + async fn get_runtime(&self, _: &ProviderAccountId) -> Result { + panic!("事实回调只使用对应的窄查询") + } + async fn get_credential( + &self, + _: &ProviderAccountId, + ) -> Result { + panic!("不允许读取凭据") + } + async fn save( + &self, + _: PreparedPluginAccountSave, + _: &MutationContext, + ) -> Result { + panic!("不允许修改账号") + } + async fn get_quota(&self, account: &ProviderAccountId) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + assert_eq!(account.as_str(), "acct_facts"); + Ok(ProviderQuota { + plan_type: Some("private plan".into()), + observed_at: None, + refresh_token_expires_at: None, + windows: vec![ProviderQuotaWindow { + key: "weekly".into(), + group: "private group".into(), + label: "private label".into(), + limit_id: None, + limit_name: None, + role: None, + local_usage_attribution: QuotaLocalUsageAttribution::Unavailable, + window_seconds: Some(604800), + used_percent: None, + reset_at: None, + limit_reached: false, + local_usage: None, + provider_data: None, + }], + limit_reached: false, + provider_data: None, + }) + } +} + +#[tokio::test] +async fn management_facts_are_minimal_bounded_and_separately_authorized() { + for permission in [None, Some(Permission::Accounts), Some(Permission::Data)] { + let (cache, store, runtime) = super::setup_with_permissions( + Contributions::from([crate::support::contribution( + Capability::Management, + vec![Stage::Management], + vec![], + vec![], + )]), + Default::default(), + permission.into_iter().collect(), + ) + .await; + let facts = Arc::new(Facts::default()); + let port: Arc = facts.clone(); + runtime.bind_account_ports(&port).unwrap(); + { + 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":[ + {"method":"host.data.accounts.list","query":{"provider_id":"openai","cursor":"acct_before","limit":1}}, + {"method":"host.data.quota.get","query":{"account_id":"acct_facts"}}, + {"method":"host.data.accounts.list","query":{"limit":0}}, + {"method":"host.data.accounts.list","query":{"limit":65535}}, + {"method":"host.data.quota.get","query":{"account_id":"acct_facts","sql":"SELECT secret"}} + ] + }); + } + let generation = + ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert!(runtime.middleware_registry().resolve(&generation).is_none()); + assert!(runtime.policy_registry().resolve(&generation).is_none()); + let view = runtime.views(&generation).await.unwrap().remove(0); + let response = runtime + .handle( + &generation, + &view.target, + PluginManagementRequest { + method: "GET".into(), + path: "facts".into(), + query: String::new(), + content_type: None, + body: vec![], + request_id: "data-fixture".into(), + }, + ) + .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":[{ + "account_id":"acct_facts","provider_id":"openai","group_ids":["grp_11111111111111111111111111111111"], + "enabled":true,"updated_at_ms":1767225600000i64 + }],"next_cursor":"acct_facts"}) + ); + 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") + ); + } + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs index abeab1dd4..b55a81894 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs @@ -1,7 +1,7 @@ mod authentication; mod configuration; +mod data; mod observer; -mod observer_example; mod policy; mod prepare; mod private_state; @@ -44,13 +44,21 @@ 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(), - Vec::new(), + permissions, contributes, ), None, diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs deleted file mode 100644 index 92f4a7a95..000000000 --- a/backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::{ - collections::BTreeMap, - num::NonZeroU32, - sync::{Arc, Mutex}, - time::Duration, -}; - -use gateway_admin::{ - model::{ - Revision, - plugins::instances::{ - PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot, - PluginPermissionGrant, - }, - }, - ports::plugins::PluginPackageInspector, -}; -use gateway_core::{ - account::ProviderAccountId, - diagnostics::diagnostic_json, - engine::{ - ModelRequestId, - observation::{WebSocketResponseAttempt, WebSocketResponseObservation}, - }, - event::ProtocolWireEvent, - identity::ProviderKind, - operation::OperationKind, - policy::ClientApiKeyId, - routing::{ConfigRevision, PublicModelId}, - runtime::extensions::{ExtensionPreparationPort, ExtensionSetReference}, -}; -use gateway_plugin_runtime::{ - PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, -}; -use serde_json::{Value, json}; - -use crate::support::store::Store; - -#[derive(Clone, Default)] -struct Capture(Arc>>>); - -struct Fields(BTreeMap); - -impl tracing::field::Visit for Fields { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - self.0.insert(field.name().to_owned(), format!("{value:?}")); - } -} - -impl tracing::Subscriber for Capture { - fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { - metadata.target() == "gateway_plugin" - } - fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { - tracing::span::Id::from_u64(1) - } - fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} - fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} - fn enter(&self, _: &tracing::span::Id) {} - fn exit(&self, _: &tracing::span::Id) {} - fn event(&self, event: &tracing::Event<'_>) { - if event.metadata().target() == "gateway_plugin" { - let mut fields = Fields(BTreeMap::new()); - event.record(&mut fields); - self.0.lock().unwrap().push(fields.0); - } - } -} - -impl Capture { - async fn wait_for(&self, count: usize) { - tokio::time::timeout(Duration::from_secs(5), async { - while self.0.lock().unwrap().len() < count { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("independent observer log callback must complete"); - } - - fn assert_summary(&self, instance: &str, generation: u64, expected: Value) { - let logs = self.0.lock().unwrap(); - let row = logs - .iter() - .rev() - .find(|row| { - row["instance_id"] == instance && row["generation"] == generation.to_string() - }) - .expect("observer generation summary"); - assert_eq!( - serde_json::from_str::(&row["fields"]).unwrap(), - diagnostic_json(&expected) - ); - assert!(!row["fields"].contains("fixture-private-payload")); - } -} - -fn grant(permission: &str) -> PluginPermissionGrant { - PluginPermissionGrant { - permission: permission.to_owned(), - } -} - -fn binding(contribution: &str) -> PluginCapabilityBinding { - PluginCapabilityBinding { - contribution: contribution.to_owned(), - stage: "observation".to_owned(), - order: 1, - failure_policy: PluginFailurePolicy::Observe, - client_key_ids: Vec::new(), - account_group_ids: Vec::new(), - provider_ids: vec!["openai".to_owned()], - models: vec!["gpt-observed".to_owned()], - identity_bindings: Vec::new(), - } -} - -async fn runtime(cache: &std::path::Path) -> (Arc, PluginRuntime) { - let inspector = PackageInspector::new(PackageLimits::default(), "3.12.1".parse().unwrap()); - let mut artifacts = BTreeMap::new(); - let mut instances = Vec::new(); - for (variable, plugin, permissions) in [ - ( - "CPR_PLUGIN_WS_COUNTER_ARCHIVE", - "example-ws-event-counter", - vec![], - ), - ( - "CPR_PLUGIN_WS_METRICS_ARCHIVE", - "example-ws-payload-metrics", - vec![grant("requests")], - ), - ] { - let path = std::env::var_os(variable).unwrap_or_else(|| { - panic!("{variable} must point to a packaged independent SDK example") - }); - let archive = std::fs::read(path).expect("independent observer archive"); - let artifact = inspector.inspect(archive.into(), None).await.unwrap(); - assert_eq!(artifact.metadata.plugin_id, plugin); - let contribution = artifact.metadata.contributes["web_socket_observer"] - .id - .clone(); - instances.push(PluginInstance { - id: plugin.to_owned(), - name: plugin.to_owned(), - artifact_sha256: artifact.metadata.sha256.clone(), - enabled: true, - trusted_process: true, - configuration: json!({}), - secrets: BTreeMap::new(), - grants: permissions, - bindings: vec![binding(&contribution)], - revision: Revision::new(1).unwrap(), - }); - artifacts.insert(artifact.metadata.sha256.clone(), artifact); - } - let store = Arc::new(Store { - artifacts, - snapshot: Mutex::new(PluginInstanceSnapshot { - config_revision: Revision::new(1).unwrap(), - instances, - }), - }); - let runtime = PluginRuntime::new( - store.clone(), - store.clone(), - PluginRuntimeConfig { - cache_directory: cache.to_owned(), - host_version: "3.12.1".parse().unwrap(), - package_limits: PackageLimits::default(), - rpc_limits: RpcLimits::default(), - restart_circuit: Default::default(), - }, - Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), - Arc::new(gateway_host::process::ProcessSupervisor::new( - std::num::NonZeroUsize::new(8).unwrap(), - )), - ); - (store, runtime) -} - -fn observation( - generation: u64, - sequence: u64, - model: &str, - body: &Value, -) -> WebSocketResponseObservation { - WebSocketResponseObservation::new( - ModelRequestId::new("req_observer_example").unwrap(), - ConfigRevision::new(generation).unwrap(), - OperationKind::Generate, - WebSocketResponseAttempt::new( - ProviderKind::new("openai").unwrap(), - ProviderAccountId::new("acct_observed").unwrap(), - NonZeroU32::new(1).unwrap(), - ), - sequence, - ProtocolWireEvent::json( - "openai", - body["type"].as_str().map(str::to_owned), - body.clone(), - ) - .unwrap(), - ) - .with_client_scope( - ClientApiKeyId::new("key_observer_example").unwrap(), - Vec::new(), - ) - .with_requested_model(PublicModelId::new(model).unwrap()) -} - -fn dispatch( - runtime: &PluginRuntime, - generation: &ExtensionSetReference, - revision: u64, - sequence: u64, - model: &str, - body: &Value, -) { - runtime - .observer_registry() - .resolve(generation) - .unwrap() - .dispatch_websocket_response( - generation.clone(), - observation(revision, sequence, model, body), - ); -} - -/// 必须单独按名称显式执行;真实包缺失时报错,不将未运行的依赖场景算作通过。 -#[tokio::test] -#[ignore = "requires two packaged standalone observer examples and an isolated log subscriber"] -async fn actual_observer_examples_share_runtime_contract_with_separate_permissions_and_generations() -{ - let capture = Capture::default(); - tracing::subscriber::set_global_default(capture.clone()).unwrap(); - let cache = tempfile::tempdir().unwrap(); - let (store, runtime) = runtime(cache.path()).await; - let first = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) - .await - .unwrap(); - let body = json!({"type":"response.output_text.delta","delta":"fixture-private-payload 中文","unknown":true}); - let bytes = serde_json::to_vec(&body).unwrap().len(); - let text_bytes = body["delta"].as_str().unwrap().len(); - dispatch(&runtime, &first, 1, 1, "gpt-observed", &body); - capture.wait_for(2).await; - dispatch(&runtime, &first, 1, 2, "other-model", &body); - dispatch(&runtime, &first, 1, 9, "gpt-observed", &body); - capture.wait_for(4).await; - capture.assert_summary( - "example-ws-event-counter", - 1, - json!({"events":2,"first_events":1,"maximum_sequence":9}), - ); - capture.assert_summary("example-ws-payload-metrics", 1, json!({"delivered_events":2,"payload_bytes":bytes*2,"text_delta_bytes":text_bytes*2,"terminal_events":0})); - { - let mut snapshot = store.snapshot.lock().unwrap(); - snapshot.config_revision = Revision::new(2).unwrap(); - for instance in &mut snapshot.instances { - instance.revision = Revision::new(2).unwrap(); - } - } - let second = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) - .await - .unwrap(); - dispatch(&runtime, &second, 2, 1, "gpt-observed", &body); - dispatch(&runtime, &first, 1, 10, "gpt-observed", &body); - capture.wait_for(8).await; - capture.assert_summary( - "example-ws-event-counter", - 2, - json!({"events":1,"first_events":1,"maximum_sequence":1}), - ); - capture.assert_summary( - "example-ws-event-counter", - 1, - json!({"events":3,"first_events":1,"maximum_sequence":10}), - ); - capture.assert_summary("example-ws-payload-metrics", 2, json!({"delivered_events":1,"payload_bytes":bytes,"text_delta_bytes":text_bytes,"terminal_events":0})); - capture.assert_summary("example-ws-payload-metrics", 1, json!({"delivered_events":3,"payload_bytes":bytes*3,"text_delta_bytes":text_bytes*3,"terminal_events":0})); - assert_eq!(capture.0.lock().unwrap().len(), 8); - runtime.shutdown().await; - drop(first); - drop(second); - super::wait_until_empty(cache.path()).await; -} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs index 6250da5a2..90cd24ac1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, VecDeque}, num::{NonZeroU32, NonZeroUsize}, sync::{ Arc, @@ -97,6 +97,7 @@ async fn setup( manifest_permissions: Vec, ) -> (tempfile::TempDir, PluginRuntime) { let contributes = Contributions::from([ + crate::support::contribution(Capability::RetryPolicy, vec![Stage::Retry], vec![], vec![]), crate::support::contribution( Capability::ModelRouter, vec![Stage::Routing], @@ -227,6 +228,124 @@ fn operation() -> Operation { )) } +#[tokio::test] +async fn retry_policy_uses_ordered_delegation_and_rejects_disallowed_actions() { + use gateway_core::{ + engine::policy::{RetryDecision, RetryFacts}, + error::ProviderErrorKind, + upstream::UpstreamSendState, + }; + for (reply, allowed, expected) in [ + ( + serde_json::json!({"decision":"delegate"}), + true, + RetryDecision::Stop, + ), + ( + serde_json::json!({"decision":"retry"}), + true, + RetryDecision::Retry, + ), + ( + serde_json::json!({"decision":"retry"}), + false, + RetryDecision::Stop, + ), + ( + serde_json::json!({"decision":"retry","account_id":"acct_forbidden"}), + true, + RetryDecision::Stop, + ), + ] { + 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; + let context = policy_context(&runtime, prepare(&runtime).await, "req_retry"); + let decision = context + .retry_decision(RetryFacts { + attempt_index: NonZeroU32::MIN, + provider: ProviderKind::new("openai").unwrap(), + model: Some("gpt-test".into()), + error_kind: ProviderErrorKind::RateLimited, + upstream_status: Some(429), + send_state: UpstreamSendState::NotSent, + remaining_routing_attempts: 1, + remaining_deadline: Duration::from_secs(3), + retry_allowed: allowed, + }) + .await; + assert_eq!(decision, expected); + let facts = marker_lines(&marker, 1).await.remove(0); + assert_eq!( + facts["allowed_actions"], + if allowed { + serde_json::json!(["stop", "retry"]) + } else { + serde_json::json!(["stop"]) + } + ); + assert_eq!(facts["error_kind"], "rate_limited"); + assert!(facts.get("account_id").is_none()); + runtime.shutdown().await; + } +} + +#[tokio::test] +async fn retry_policy_timeout_exhausts_the_chain_budget_and_delegates() { + use gateway_core::{ + engine::policy::{RetryDecision, RetryFacts}, + error::ProviderErrorKind, + upstream::UpstreamSendState, + }; + let records = tempfile::tempdir().unwrap(); + let second_marker = records.path().join("second.jsonl"); + let (_cache, runtime) = setup( + vec![ + 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( + Duration::from_secs(2), + context.retry_decision(RetryFacts { + attempt_index: NonZeroU32::MIN, + provider: ProviderKind::new("openai").unwrap(), + model: Some("gpt-test".into()), + error_kind: ProviderErrorKind::RateLimited, + upstream_status: Some(429), + send_state: UpstreamSendState::NotSent, + remaining_routing_attempts: 1, + remaining_deadline: Duration::from_millis(100), + retry_allowed: true, + }), + ) + .await + .expect("the shared deadline bounds the whole retry chain"); + assert_eq!(decision, RetryDecision::Delegate); + assert!( + !second_marker.exists(), + "later policies cannot restart an exhausted budget" + ); + runtime.shutdown().await; +} + async fn marker_lines(path: &std::path::Path, count: usize) -> Vec { tokio::time::timeout(Duration::from_secs(3), async { loop { @@ -1055,6 +1174,270 @@ async fn instances_without_data_plane_bindings_publish_neither_plan() { super::wait_until_empty(cache.path()).await; } +struct TransparencyNext { + expected: MiddlewareRequest, + headers: Vec, + frames: VecDeque, +} + +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, + )]))), + )) + }) + } +} + +#[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), + ] { + let stage = if attempt { + Stage::Attempt + } else { + Stage::Request + }; + let (capability, mut contribution) = crate::support::contribution( + Capability::Middleware, + vec![stage], + vec!["openai".into()], + 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 { + id: "capability-declaration", + 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; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let context = if attempt { + attempt_middleware_context(Arc::default()) + } else { + middleware_context(ClientTransport::HttpJson) + }; + 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 { + let mut body = result.unwrap().into_parts().3; + assert!(body.next_frame().await.unwrap().unwrap().terminal()); + assert!(body.next_frame().await.unwrap().is_none()); + body.close().await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + } else { + assert!(result.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + runtime.shutdown().await; + } +} + +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)), + )) + }) + } +} + +struct TransparencyBody(VecDeque); + +impl MiddlewareBody for TransparencyBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { Ok(self.0.pop_front()) }) + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async {}) + } +} + +async fn assert_sdk_transparency(mode: &'static str, authorized: bool) { + 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], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let (cache, runtime) = setup_package( + vec![InstanceFixture { + id: "transparency", + configuration: serde_json::json!({"mode": mode}), + grants: permissions.into_iter().map(grant).collect(), + 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 mut headers = middleware_headers(); + headers.extend([ + MiddlewareHeader::new("X-Repeated", Bytes::from_static(b"first")), + MiddlewareHeader::new("X-Repeated", Bytes::from_static(b"second")), + MiddlewareHeader::new("x-binary", Bytes::from_static(b"\x80\xff")), + ]); + let response_headers = vec![ + MiddlewareHeader::new("x-repeated", Bytes::from_static(b"first")), + MiddlewareHeader::new("x-repeated", Bytes::from_static(b"second")), + MiddlewareHeader::new("set-cookie", Bytes::from_static(b"test-hidden=kept")), + MiddlewareHeader::new("x-binary", Bytes::from_static(b"\x80\xff")), + ]; + let json = Bytes::from_static( + br#" { "z": 18446744073709551615, "unknown": { "x": 1e2 }, "a": "\u0061" } "#, + ); + let cases = [ + (ClientTransport::HttpJson, MiddlewareFraming::JsonDocument, vec![json.clone()]), + (ClientTransport::HttpSse, 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 request_body in [json.clone(), Bytes::new()] { + let request = MiddlewareRequest::new("openai", headers.clone(), request_body.clone()); + let mut expected_headers = headers.clone(); + let mut expected_response_headers = response_headers.clone(); + if mode == "headers" { + expected_headers.push(MiddlewareHeader::new( + "x-sdk-request", + Bytes::from_static(b"active"), + )); + expected_response_headers.push(MiddlewareHeader::new( + "x-sdk-response", + Bytes::from_static(b"active"), + )); + } + let next = TransparencyNext { + expected: MiddlewareRequest::new("openai", expected_headers, request_body), + headers: response_headers.clone(), + frames: payloads + .iter() + .enumerate() + .map(|(index, payload)| { + MiddlewareFrame::new(payload.clone(), framing, index + 1 == payloads.len()) + }) + .collect(), + }; + let response = plan + .handle(middleware_context(transport), request, Box::new(next)) + .await + .unwrap(); + let (protocol, status, actual_headers, mut body, _) = response.into_parts(); + assert_eq!(protocol, "openai"); + assert_eq!(status, 200); + assert_eq!(actual_headers, expected_response_headers); + for (index, expected) in payloads.iter().enumerate() { + let frame = body.next_frame().await.unwrap().unwrap(); + assert!( + !frame.transformed(), + "read-only inspection must retain provenance" + ); + assert_eq!(frame.terminal(), index + 1 == payloads.len()); + assert_eq!(frame.into_bytes(), *expected); + } + 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_passthrough_preserves_original_bytes_and_hidden_headers() { + assert_sdk_transparency("passthrough", true).await; + assert_sdk_transparency("passthrough", false).await; +} + +#[tokio::test] +async fn sdk_read_only_inspection_preserves_original_bytes_and_event_order() { + assert_sdk_transparency("inspect", true).await; +} + +#[tokio::test] +async fn sdk_header_mutations_preserve_original_body_and_other_headers() { + assert_sdk_transparency("headers", true).await; +} + #[tokio::test] async fn a_failed_plugin_rejects_only_its_bound_models_and_preserves_delegate_policy() { for failure_policy in [PluginFailurePolicy::Reject, PluginFailurePolicy::Delegate] { 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 e05ca876e..b6c84156e 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs @@ -27,6 +27,120 @@ fn startup_count(marker: &Path) -> usize { .unwrap_or_default() } +#[tokio::test] +async fn model_catalog_is_frozen_without_entering_the_request_chain() { + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + )])) + .await; + store.snapshot.lock().unwrap().instances[0].configuration = serde_json::json!({ + "model_catalog": {"models": [{"id": "my-model", "provider": "openai", "model": "upstream-model"}]} + }); + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert_eq!(generation.model_aliases().len(), 1); + assert_eq!(generation.model_aliases()[0].id.as_str(), "my-model"); + assert_eq!( + generation.model_aliases()[0].target.as_str(), + "upstream-model" + ); + assert!(runtime.policy_registry().resolve(&generation).is_none()); + assert!(runtime.middleware_registry().resolve(&generation).is_none()); + let reused = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert_eq!(reused.id(), generation.id()); + assert_eq!(reused.model_aliases(), generation.model_aliases()); + { + let mut snapshot = store.snapshot.lock().unwrap(); + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[0].revision = Revision::new(2).unwrap(); + snapshot.instances[0].configuration = serde_json::json!({"startup":"fail"}); + } + assert!( + ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .is_err(), + "failed recovery must not publish a catalog with silently removed aliases" + ); + assert!(generation.is_ready()); + assert_eq!(generation.model_aliases()[0].id.as_str(), "my-model"); + { + let mut snapshot = store.snapshot.lock().unwrap(); + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[0].enabled = false; + } + let disabled = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + assert!(disabled.model_aliases().is_empty()); + assert_eq!(generation.model_aliases().len(), 1); + drop(disabled); + drop(reused); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn model_catalog_rejects_invalid_registrations_and_cross_instance_conflicts() { + for models in [ + serde_json::json!([{"id":"alias","provider":"custom","model":"target"}]), + serde_json::json!([{"id":"alias","provider":"openai","model":"alias"}]), + serde_json::json!([ + {"id":"alias","provider":"openai","model":"target"}, + {"id":"alias","provider":"xai","model":"target"} + ]), + serde_json::json!([{"id":"alias","provider":"openai","model":"target","capabilities":[]}]), + ] { + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + )])) + .await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = + serde_json::json!({"model_catalog":{"models":models}}); + assert!( + PluginPreparation::prepare(&runtime, snapshot) + .await + .is_err() + ); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + } + + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::ModelCatalog, + vec![Stage::Registration], + vec![], + vec![], + )])) + .await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "model_catalog":{"models":[{"id":"alias","provider":"openai","model":"target"}]} + }); + let mut other = snapshot.instances[0].clone(); + other.id = "instance-two".into(); + snapshot.instances.push(other); + let error = PluginPreparation::prepare(&runtime, snapshot) + .await + .unwrap_err(); + assert!(error.to_string().contains("instance-one")); + assert!(error.to_string().contains("instance-two")); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + #[tokio::test] async fn runtime_shutdown_reaps_live_generation_and_rejects_late_preparation() { let (cache, store, runtime) = super::setup().await; diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs index 931001f2b..758b77c9e 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -75,6 +75,8 @@ fn assert_exhaustive_capability(capability: Capability) { Capability::FrontendAuthentication | Capability::Scheduler | Capability::ModelRouter + | Capability::ModelCatalog + | Capability::RetryPolicy | Capability::Middleware | Capability::RequestLifecycle | Capability::WebSocketObserver @@ -90,15 +92,18 @@ fn assert_exhaustive_permission(permission: Permission) { | Permission::Requests | Permission::Models | Permission::Accounts + | Permission::Data | Permission::PublicEndpoints => {} } } -fn all_capabilities() -> [Capability; 9] { +fn all_capabilities() -> [Capability; 11] { [ Capability::FrontendAuthentication, Capability::Scheduler, Capability::ModelRouter, + Capability::ModelCatalog, + Capability::RetryPolicy, Capability::Middleware, Capability::RequestLifecycle, Capability::WebSocketObserver, diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs index c8aab94e4..1e82d4efc 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs @@ -234,3 +234,58 @@ async fn execution_allows_each_declared_callback_permission() { session.shutdown(Duration::from_secs(1)).await; } + +#[tokio::test] +async fn data_callbacks_require_independent_grants_and_management_or_command_stage() { + for permissions in [vec![Permission::Accounts], vec![Permission::Data]] { + let authorized = permissions.contains(&Permission::Data); + 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, + ] { + for method in ["host.data.accounts.list", "host.data.quota.get"] { + let reply = invoke_callback(&session, stage, method).await; + if authorized && matches!(stage, Stage::Management | Stage::CommandLine) { + assert!(reply.is_ok()); + } else { + assert_permission_denied(reply); + } + } + } + assert_eq!( + callbacks.called.load(Ordering::Relaxed), + if authorized { 4 } 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; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs index 290d34db3..1879c0dfc 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs @@ -5,34 +5,77 @@ use gateway_plugin_sdk::{ #[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: 1, - stages: vec![Stage::Request], + 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()], }, )]), - |call: MiddlewareCall| async move { - let mut response = call.next.run(call.request).await?; - 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) + 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) + } }, )?; - PluginSession::accept( - tokio::io::stdin(), - tokio::io::stdout(), - SessionConfig::default(), - ) - .await? - .run(plugin) - .await?; + 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 da9e384d3..e14d8c214 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs @@ -42,6 +42,8 @@ pub fn contribution_for_id( Capability::FrontendAuthentication => "frontendAuthentication", Capability::Scheduler => "scheduler", Capability::ModelRouter => "modelRouter", + Capability::ModelCatalog => "modelCatalog", + Capability::RetryPolicy => "retryPolicy", Capability::Middleware => "middleware", Capability::RequestLifecycle => "requestLifecycle", Capability::WebSocketObserver => "webSocketObserver", diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs index 3b01c077e..169abac8b 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs @@ -297,6 +297,19 @@ impl Peer { return; } match method.as_str() { + "policy.retry_decision" => { + self.append_observation_marker("retry_marker", ¶ms); + if let Some(delay) = self.configuration["retry_delay_ms"].as_u64() { + tokio::time::sleep(Duration::from_millis(delay)).await; + } + let result = self + .configuration + .get("retry_decision") + .cloned() + .unwrap_or_else(|| json!({"decision":"delegate"})); + self.send(Message::Result { id, result }, vec![]).await; + return; + } "frontend_auth.identifier" => { self.send( Message::Result { @@ -348,6 +361,17 @@ impl Peer { .await; return; } + "model_catalog.register" => { + self.send( + Message::Result { + id, + result: json!({}), + }, + serde_json::to_vec(&self.configuration["model_catalog"]).unwrap(), + ) + .await; + return; + } "management.register" => { self.send( Message::Result { @@ -366,6 +390,35 @@ impl Peer { "management_marker", &json!({"method":request.method,"path":request.path}), ); + if let Some(queries) = self.configuration["data_queries"].as_array() { + let mut results = vec![]; + for query in queries { + let result = self + .callback_payload( + id, + query["method"].as_str().unwrap(), + json!({}), + serde_json::to_vec(&query["query"]).unwrap(), + ) + .await; + results.push(match result { + Ok((metadata, payload)) => { + assert_eq!(metadata, json!({})); + serde_json::from_slice::(&payload).unwrap() + } + Err(error) => json!({"error":error.code}), + }); + } + self.send( + Message::Result { + id, + result: json!({"status":200,"content_type":"application/json"}), + }, + serde_json::to_vec(&results).unwrap(), + ) + .await; + return; + } if let Err(error) = self .run_nested_model_fixture( id, @@ -679,6 +732,7 @@ impl Peer { protocol: None, header_mutations: Vec::new(), body: MiddlewareRequestBody::Preserve, + capabilities: None, }) .unwrap(), vec![], diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index e01bf2f94..25780fe6a 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -25,7 +25,9 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声 | `call::frontend_authentication` | 数据面认证信封、认证器标识与外部 principal 结果 | | `call::model` | 宿主模型调用的 canonical 事实、原生 wire 与有界事件编解码 | | `call::middleware` | 洋葱中间件请求/响应 head、single-use next 与惰性正文 frame 合同 | -| `call::policy` | 模型路由、账号调度及终态用量观察合同 | +| `call::policy` | 模型路由、账号调度、受约束重试及终态用量观察合同 | +| `call::catalog` | 固定 Provider 的模型别名注册合同 | +| `call::data` | 最小账号事实与已有额度观测的只读合同 | | `call::observation` | 实际上游 WebSocket 响应帧的只读观察合同 | | `call::host` | 账号、私有状态、受管 HTTP、模型、亲和、日志及调用内流的宿主回调合同 | | `call::management` | 管理 API、页面与资源声明、公开回调,以及 CLI 命令与待保存账号 | diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index 08f2bd83d..5a4e53353 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -46,6 +46,8 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | --- | --- | | `middleware` | `PluginBuilder::middleware` / `middleware.handle` | | `model_router` | `methods::ROUTE_MODEL` | +| `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` | @@ -53,7 +55,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | `management` | `PluginBuilder::management`;公开回调另用 `methods::MANAGEMENT_CALLBACK` | | `command_line` | `PluginBuilder::command_line` | -## 五个访问域 +## 访问域 安装时接受清单声明的访问域,不存在另一套逐方法、逐用途或资源 ID 白名单。宿主仍会检查父调用、阶段、 期限、实例代次、资源归属,以及 Key、账号和 Provider 自身的业务规则。 @@ -63,6 +65,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | `network` | 通过 `host.http.*` 使用宿主受管出站网络;仍受统一代理、超时、大小和流控规则约束 | | `models` | 列出非秘密 Key、按所选 Key 查询模型,以及通过 `host.model.*` 调用模型;调用可能产生消耗 | | `accounts` | 查询账号、读取原始凭据及创建或替换账号;写入仍经过 revision CAS、审计和发布事务 | +| `data` | 在管理/命令阶段只读全部账号的最小基础信息及已有额度观测,不包含凭据、写入或预测 | | `requests` | 参与请求/响应处理、路由、调度、观察及亲和查询 | | `public_endpoints` | 提供无需登录即可访问的已声明静态资源或一次性票据回调 | @@ -73,6 +76,37 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 所有回调都通过当前 `TypedCall.host` 发起,并受父调用截止时间和取消信号约束。 +### 基础事实 + +纯展示页面声明 `management` 和 `data` 即可,不需要请求处理 binding 或 `accounts` 权限。 +`data` 是独立的管理员授权域,只允许 `management`、`command_line` 阶段使用;请求链、注册及公开回调均拒绝。 +它可读取全部账号的下列最小事实,不继承或授予客户端 Key 的模型执行权。 + +| SDK 方法 | 回调 | 查询与结果 | +| --- | --- | --- | +| `call.host.account_facts(query)` | `host.data.accounts.list` | `AccountFactsQuery`:可选 `provider_id`、`cursor`,必填 `limit`(1~200);按账号 ID 升序,`next_cursor=null` 表示本页已结束 | +| `call.host.quota_facts(query)` | `host.data.quota.get` | `QuotaFactsQuery { account_id }`:读取 Provider 现有观测,不访问上游刷新 | + +类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;结果固定 `schema_version=1`。 +账号仅返回 `account_id`、`provider_id`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带姓名、邮箱、令牌或代理信息。 +额度仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。 +时间均为 UTC Unix 毫秒,比例为百分数;未知值保留 `null`,不能解释为 0。`observed_at_ms=null` 表示没有可用观测时间, +不保证当前缓存新鲜,不提供历史快照或多个查询之间的原子一致性。 + +```rust,ignore +use gateway_plugin_sdk::call::data::{AccountFactsQuery, QuotaFactsQuery}; +let page = call.host.account_facts(AccountFactsQuery { + provider_id: Some("openai".into()), cursor: None, limit: 100, +}).await?; +for account in page.accounts { + let quota = call.host.quota_facts(QuotaFactsQuery { account_id: account.account_id }).await?; + // 插件自行解释样本的新鲜度并计算展示或预测结果。 +} +``` + +已有 `usage` 观察提供请求最终用量、时间和结算事实;该投递有界、不是历史补偿接口。 +本接口不提供 SQL、池汇总、健康分、额度预测或历史使用记录查询;插件结果保存在自身 `host.state.*` 中。 + ### 账号与凭据 `accounts` 域开放 `host.auth.list/get_runtime/get/save`: @@ -127,9 +161,80 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 `MiddlewareRequestBody::Preserve`,只有 `replace_body(Vec::new())` 才表示清空。Header 修改采用增量 remove/append,保留合法多值。`MiddlewareBody::map_frames` 复用会话的流控和唯一终态,不建立插件全局流表。 +**不改写就 1:1 保留**:同一网关处理边界上,默认 `next.run(request)` 保留原始正文、未修改的 header 和响应流字节及顺序。 +只解析 `request.body` 不会触发替换;SDK 比较原投影,宿主持有的隐藏 header 不会被脱敏视图覆盖。 +只观察响应可使用 `response.body.inspect_frames(|frame| { /* 读取,不修改 */ })?`,保留来源信封、背压和取消语义。 +这一保证比较插件前后的同一边界,Provider 自身的协议适配、两次调用的随机值和网络分段不在比较范围内。 + +需要声明功能转换时,在清单选择 `middleware.version=2`,普通 v1 插件仍可原样运行。 +`request.declare_capabilities(CapabilityDeclaration { handled, required })` 随单次 `next` 发出,必须同时显式替换正文且具有 `requests` 权限。 +当前仅允许 OpenAI 生成请求的 request 阶段,HTTP 与 WebSocket 共用规则;不扩展端点允许的协议或流模式。 +`handled` 只允许列举改写前确实存在、改写后已从实际请求移除的 `tools`、`vision`、`reasoning`、`json_schema`, +插件须负责对应的提示词转换及普通响应、错误、流式响应还原。`required` 为额外上游需求,不能覆盖正文推导出的要求。 +重复项、同一项同时出现在两组、空声明、v1 使用声明、attempt 阶段使用声明均会拒绝。 +原始语义中未由插件承担的功能继续约束路由;实际正文重新加入某功能时,该需求仍生效。 +原生续接不能由声明豁免,attempt 阶段的既有冻结与保护字段检查继续生效。 + +```rust,ignore +use gateway_plugin_sdk::call::middleware::{CapabilityDeclaration, RequestFeature}; +// 将 JSON Schema 转为提示词前保留原 schema,响应返回时按它验证并还原。 +request.replace_body(convert_schema_to_prompt(&request.body)?); +request.declare_capabilities(CapabilityDeclaration { + handled: vec![RequestFeature::JsonSchema], required: vec![], +}); +let response = next.run(request).await?; +restore_and_validate_response(response).await +``` + 仅实现中间件时也可使用轻量的 `MiddlewarePlugin`;需要与管理或其他方法组合时使用 `PluginBuilder`,两者复用相同的 `MiddlewareCall` 和 `MiddlewareResponse`。 +### 模型目录 + +`model_catalog` v1 在 registration 阶段一次提交 `ModelCatalogRegistration { models: Vec }`,不需要请求级 binding。 +每条 `ModelAlias { id, provider, model }` 定义公开 ID、内置 Provider(`openai` 或 `xai`)及直接上游目标。 +上游目标须已存在于该 Provider 的当前目录;同名原生模型、静态映射、其他插件条目、别名链和循环均拒绝发布。 +每实例最多 256 条,整个二进制 JSON 最多 64 KiB;不允许覆盖原生元数据或自行宣称目标不支持的功能。 + +```rust,ignore +use gateway_plugin_sdk::call::catalog::{ModelAlias, ModelCatalogRegistration}; +let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? + .model_catalog(ModelCatalogRegistration { models: vec![ModelAlias { + id: "team-default".into(), provider: "openai".into(), model: "gpt-target".into(), + }] })? + .build()?; +``` + +有效目录、路由和目标能力随同一个不可变快照发布,`/v1/models`、详情、原生目录和 `host.models.list` 共用该事实及现有 Key/账号模型范围。 +原生别名继承目标的完整可公开对象;更新失败保留旧快照,禁用后新请求不再使用该别名,在途请求持有旧代次。 +v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须通过宿主的权限和能力校验,不提供多目标虚拟模型注册合同。 + +### 重试决策 + +`retry_policy` v1 固定绑定 `retry` 阶段,故障策略只能为 `delegate`。使用 `methods::RETRY_DECISION` 处理 `RetryDecisionRequest`。 +输入包含失败分类、必要状态码、发送状态、attempt 序号、Provider/上游模型、剩余路由次数和期限,以及 `allowed_actions`。 +不发送账号凭据、请求正文或上游错误转储。路由次数不等同于 Provider 自有传输恢复预算。 + +返回 `RetryDecision::Delegate`、`Stop` 或 `Retry`。`Retry` 必须出现在宿主允许动作中,并且仅继续宿主已经选定的恢复路径; +不能指定账号、延迟、目标或提高预算。响应已交付、发送不明确、外部副作用及续接绑定等既有安全门不可绕过。 +Core 处理建立响应流前与流中的失败,并在策略返回后再次检查期限、取消和副作用。 + +```rust,ignore +use gateway_plugin_sdk::{client::TypedReply, call::policy::RetryDecision}; +let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? + .on(methods::RETRY_DECISION, |call| async move { + let decision = if call.request.upstream_status == Some(429) { + RetryDecision::Stop + } else { RetryDecision::Delegate }; + Ok(TypedReply::new(decision)) + })? + .build()?; +``` + +按 binding 顺序、插件 ID、实例 ID 确定委托链,首个合法非委托结果生效。整条链最多等待 2 秒,并受请求剩余期限约束。 +退出、超时、未知字段及不允许的动作委托后续处理,最终回到宿主决定;故障不改写原始上游错误。 +本阶段宿主只开放日志与插件私有状态回调,不允许额外网络、模型调用或账号访问。middleware 的 `next` 仍最多调用一次。 + ### 路由、调度与观察 `policy.route_model` 和 `policy.schedule_account` 只提出决定,宿主随后复核 Key 规则、模型能力、账号资格和租约。 diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index a6bb98b75..acd0662bf 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -40,12 +40,13 @@ } ``` -默认扩展项 ID 为 `..`,版本固定为 `1`。除 `middleware` 外,阶段由 +默认扩展项 ID 为 `..`,版本默认为 `1`;使用能力需求声明的 `middleware` 选择 `2`。除 `middleware` 外,阶段由 capability 固定并由工具生成: | 阶段 | 能力 | | --- | --- | | `authentication` / `routing` / `scheduling` | `frontend_authentication` / `model_router` / `scheduler` | +| `registration` / `retry` | `model_catalog` / `retry_policy` | | `observation` | `request_lifecycle`、`web_socket_observer`、`usage` | | `management` / `command_line` | `management` / `command_line` | @@ -54,19 +55,20 @@ capability 固定并由工具生成: ## 权限 -权限只有五个稳定访问域: +权限使用以下稳定访问域: | 标识 | 含义 | | --- | --- | | `network` | 使用宿主受管网络 | | `models` | 查询非秘密 Key 与模型并调用模型,可能产生消耗 | | `accounts` | 查询、读取原始凭据和修改账号 | +| `data` | 仅在管理与命令阶段只读全部账号的基础信息和已有额度观测 | | `requests` | 查看和处理请求、响应、路由、调度与观察事实 | | `public_endpoints` | 提供无需登录即可访问的资源或回调 | 安装时统一接受清单声明的域,不再填写逐方法、用途、Key、账号或 Provider 白名单。日志与清单声明的 本插件私有状态是基础设施,无需单独 permission。权限并不替代方法阶段、父调用、Key 规则、账号 revision、 -资源归属和流生命周期校验,具体接口见[能力与回调](capabilities.md#五个访问域)。 +资源归属和流生命周期校验,具体接口见[能力与回调](capabilities.md#访问域)。 ## 插件图标 diff --git a/backend/crates/gateway-plugin/sdk/src/call/catalog.rs b/backend/crates/gateway-plugin/sdk/src/call/catalog.rs new file mode 100644 index 000000000..9affd10d5 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/catalog.rs @@ -0,0 +1,19 @@ +//! 固定 Provider 的模型别名贡献;目标功能与协议元数据由宿主目录提供。 + +use serde::{Deserialize, Serialize}; + +/// 一次实例准备产生的完整目录,随实例配置原子发布。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModelCatalogRegistration { + pub models: Vec, +} + +/// 指向内置 Provider 上游模型的直接别名,不创建新的 Provider 执行器。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ModelAlias { + pub id: String, + pub provider: String, + pub model: String, +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/data.rs b/backend/crates/gateway-plugin/sdk/src/call/data.rs new file mode 100644 index 000000000..a99942f77 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/data.rs @@ -0,0 +1,58 @@ +//! 只读基础事实;版本 1 不提供凭据、预测、聚合或主动上游刷新。 + +use serde::{Deserialize, Serialize}; + +pub const ACCOUNTS_LIST: &str = "host.data.accounts.list"; +pub const QUOTA_GET: &str = "host.data.quota.get"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccountFactsQuery { + pub provider_id: Option, + pub cursor: Option, + pub limit: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccountFacts { + pub account_id: String, + pub provider_id: String, + pub group_ids: Vec, + pub enabled: bool, + pub updated_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccountFactsPage { + pub schema_version: u32, + pub accounts: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuotaFactsQuery { + pub account_id: String, +} + +/// Provider 已有快照的必要投影;空观测时间表示没有可用样本,不代表额度为零。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuotaFacts { + pub schema_version: u32, + pub account_id: String, + pub observed_at_ms: Option, + pub windows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuotaWindowFacts { + pub key: String, + pub window_seconds: Option, + /// 百分比而非 0~1 比率;未知值为 null。 + pub used_percent: Option, + pub reset_at_ms: Option, +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware.rs index d65e15346..0de32e0db 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/middleware.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware.rs @@ -88,6 +88,24 @@ pub enum MiddlewareRequestBody { Replace, } +/// 可声明的请求功能;原生续接始终由宿主与 Provider 管理,不能由声明豁免。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestFeature { + Tools, + Vision, + Reasoning, + JsonSchema, +} + +/// middleware v2:插件承担的功能转换与额外上游需求,均不覆盖正文推导的事实。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CapabilityDeclaration { + pub handled: Vec, + pub required: Vec, +} + /// 单次 `host.middleware.next` 调用。`Replace` 的完整正文位于 binary payload。 #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -97,6 +115,8 @@ pub struct MiddlewareNextRequest { #[serde(default)] pub header_mutations: Vec, pub body: MiddlewareRequestBody, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, } /// 受父调用约束的不透明响应正文句柄。 diff --git a/backend/crates/gateway-plugin/sdk/src/call/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/mod.rs index 8a15986be..4ba086e39 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/mod.rs @@ -1,5 +1,7 @@ //! 按业务职责组织的跨进程调用数据,不依赖网关领域类型。 +pub mod catalog; +pub mod data; pub mod frontend_authentication; pub mod host; pub mod management; 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 808bc7066..4e5b5333b 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs @@ -4,6 +4,38 @@ use serde::{Deserialize, Serialize}; use crate::SendState; +/// `retry` 只继续宿主已选定的恢复路径,不能改账号、延迟或预算。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetryAction { + Stop, + Retry, +} + +/// 重试输入只包含安全事实,不携带上游错误正文、凭据或请求内容。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetryDecisionRequest { + pub request_id: String, + pub attempt_index: u32, + pub provider: String, + pub model: Option, + pub error_kind: String, + pub upstream_status: Option, + pub send_state: SendState, + pub remaining_routing_attempts: u32, + pub remaining_deadline_ms: u64, + pub allowed_actions: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case", deny_unknown_fields)] +pub enum RetryDecision { + Delegate, + Stop, + Retry, +} + /// 策略插件可见的安全 HTTP 头;值使用 base64 保留非 UTF-8 字节。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/backend/crates/gateway-plugin/sdk/src/capability.rs b/backend/crates/gateway-plugin/sdk/src/capability.rs index d86fc4893..9024aaaec 100644 --- a/backend/crates/gateway-plugin/sdk/src/capability.rs +++ b/backend/crates/gateway-plugin/sdk/src/capability.rs @@ -9,6 +9,8 @@ pub enum Capability { FrontendAuthentication, Scheduler, ModelRouter, + ModelCatalog, + RetryPolicy, Middleware, RequestLifecycle, WebSocketObserver, @@ -25,6 +27,8 @@ impl Capability { Self::FrontendAuthentication => "frontend_authentication", Self::Scheduler => "scheduler", Self::ModelRouter => "model_router", + Self::ModelCatalog => "model_catalog", + Self::RetryPolicy => "retry_policy", Self::Middleware => "middleware", Self::RequestLifecycle => "request_lifecycle", Self::WebSocketObserver => "web_socket_observer", @@ -41,6 +45,8 @@ impl Capability { Self::FrontendAuthentication => &[Stage::Authentication], Self::Scheduler => &[Stage::Scheduling], Self::ModelRouter => &[Stage::Routing], + Self::ModelCatalog => &[Stage::Registration], + Self::RetryPolicy => &[Stage::Retry], Self::Middleware => &[], Self::RequestLifecycle | Self::WebSocketObserver | Self::Usage => &[Stage::Observation], Self::CommandLine => &[Stage::CommandLine], @@ -58,6 +64,7 @@ pub enum Stage { Authentication, Routing, Scheduling, + Retry, Request, Attempt, Observation, @@ -134,16 +141,18 @@ pub enum Permission { Network, Models, Accounts, + Data, Requests, PublicEndpoints, } impl Permission { /// 当前公开访问域,安装摘要与授权校验复用同一集合。 - pub const ALL: [Self; 5] = [ + pub const ALL: [Self; 6] = [ Self::Network, Self::Models, Self::Accounts, + Self::Data, Self::Requests, Self::PublicEndpoints, ]; @@ -154,6 +163,7 @@ impl Permission { Self::Network => "network", Self::Models => "models", Self::Accounts => "accounts", + Self::Data => "data", Self::Requests => "requests", Self::PublicEndpoints => "public_endpoints", } @@ -166,6 +176,7 @@ impl Permission { Self::Network => "联网", Self::Models => "模型调用", Self::Accounts => "账号与凭据", + Self::Data => "基础数据", Self::Requests => "请求处理", Self::PublicEndpoints => "公开入口", } @@ -178,6 +189,9 @@ impl Permission { Self::Network => "访问网络", Self::Models => "查询模型与 API Key 信息并调用模型,可能产生消耗", Self::Accounts => "读取和修改账号,包括访问原始凭据", + Self::Data => { + "在管理或命令入口只读查询所有账号的基础信息与已有额度观测,不包含凭据或修改权限" + } Self::Requests => "查看和处理请求、响应、路由及账号选择", Self::PublicEndpoints => "提供无需登录即可访问的资源与回调入口", } diff --git a/backend/crates/gateway-plugin/sdk/src/client/data.rs b/backend/crates/gateway-plugin/sdk/src/client/data.rs new file mode 100644 index 000000000..fd453f6c4 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/data.rs @@ -0,0 +1,51 @@ +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{ErrorCode, PluginFault, call::data}; + +use super::{HostClient, SessionError}; + +impl HostClient { + /// 在已授权的 management/command_line 调用中分页读取账号基础事实。 + /// + /// # Errors + /// + /// 未获得 data 权限、调用阶段不符、参数无效或宿主读取失败时返回错误。 + pub async fn account_facts( + &self, + query: data::AccountFactsQuery, + ) -> Result { + data_call(self, data::ACCOUNTS_LIST, query).await + } + + /// 读取已有额度观测,不触发上游刷新,也不返回宿主预测结果。 + /// + /// # Errors + /// + /// 未获得 data 权限、调用阶段不符、账号不存在或宿主读取失败时返回错误。 + pub async fn quota_facts( + &self, + query: data::QuotaFactsQuery, + ) -> Result { + data_call(self, data::QUOTA_GET, query).await + } +} + +async fn data_call( + host: &HostClient, + method: &str, + query: T, +) -> Result { + let invalid = || PluginFault::new(ErrorCode::InvalidInput, "invalid data callback payload"); + let reply = host + .call( + method, + serde_json::json!({}), + serde_json::to_vec(&query).map_err(|_| invalid())?, + ) + .await + .map_err(SessionError::into_plugin_fault)?; + if reply.result != serde_json::json!({}) { + return Err(invalid()); + } + serde_json::from_slice(&reply.payload).map_err(|_| invalid()) +} 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 f4aed2e4c..d724e65c8 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/body.rs @@ -85,6 +85,7 @@ impl MiddlewareBody { } /// 拉取下一完整 frame。调用后该 opaque handle 不能再作为未读直通返回。 + /// 只读观察并原样交付时使用 [`Self::inspect_frames`],由 SDK 保留源帧。 pub async fn read(&mut self) -> Result, PluginFault> { let MiddlewareBodySource::Host { body, @@ -159,6 +160,24 @@ impl MiddlewareBody { Ok(()) } + /// 按下游 Credit 只读观察完整 frame,并原样交付其字节、顺序和源终态。 + /// + /// 不预读或重编码正文;闭包只取得借用,不能改写正在交付的帧。 + /// 与映射共用取消和背压边界,观察工作应保持有界。 + /// + /// # Errors + /// + /// 正文来自插件自建流时返回错误;空正文直接保留。 + pub fn inspect_frames(self, mut inspect: F) -> Result + where + F: FnMut(&MiddlewareBodyFrame) + Send + 'static, + { + self.map_frames(move |frame| { + inspect(&frame); + Ok(vec![frame]) + }) + } + /// 按下游 Credit 惰性拉取并映射完整 frame;闭包可在单次调用内持有状态。 /// /// 每个输入可生成零个、一个或多个输出。SDK 不预读或另起后台任务;Runtime 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 558c20c34..0129f19e4 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs @@ -28,9 +28,18 @@ pub struct MiddlewareRequest { body_visible: bool, body_replaced: bool, header_mutations: Vec, + capabilities: Option, } impl MiddlewareRequest { + /// 仅 middleware v2 的 request 阶段可声明转换;须同时替换正文并负责响应还原。 + pub fn declare_capabilities( + &mut self, + declaration: crate::call::middleware::CapabilityDeclaration, + ) { + self.capabilities = Some(declaration); + } + /// 明确替换请求正文;空 Vec 也表示 replace-empty,而不是保留原正文。 pub fn replace_body(&mut self, body: Vec) { self.body = body; @@ -78,6 +87,7 @@ impl MiddlewareRequest { protocol, header_mutations, body, + capabilities: self.capabilities, }, payload, ) @@ -317,6 +327,7 @@ impl MiddlewareCall { original_protocol, original_headers, header_mutations: Vec::new(), + capabilities: None, head, }, next: MiddlewareNext { host: host.clone() }, 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 d98e08d6c..820a291f5 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs @@ -58,7 +58,7 @@ where return Err(SessionError::Configuration); }; if contributes.len() != 1 - || declaration.version != 1 + || !matches!(declaration.version, 1 | 2) || declaration.stages.is_empty() || declaration.stages.len() > 2 || declaration diff --git a/backend/crates/gateway-plugin/sdk/src/client/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/mod.rs index 9ce0f23b6..a36906f81 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/mod.rs @@ -1,5 +1,6 @@ //! 可选的插件侧异步传输与会话辅助;SDK 不启动网关服务。 +mod data; mod frame; mod middleware; mod plugin; 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 07cbcc619..94bcd9af6 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs @@ -2,7 +2,7 @@ use crate::{ Capability as C, Stage as S, - call::{frontend_authentication as frontend, host, management, observation, policy}, + call::{catalog, frontend_authentication as frontend, host, management, observation, policy}, }; use super::{ @@ -13,6 +13,22 @@ use super::{ }, }; +pub const MODEL_CATALOG_REGISTER: Method = Method::new( + "model_catalog.register", + &[C::ModelCatalog], + &[S::Registration], + decode_metadata_without_payload, + encode_payload, +); + +pub const RETRY_DECISION: Method = Method::new( + "policy.retry_decision", + &[C::RetryPolicy], + &[S::Retry], + decode_metadata_without_payload, + encode_metadata, +); + pub const MANAGEMENT_REGISTER: Method = Method::new( "management.register", &[C::Management], 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 25c14e9d7..c9e4f3d41 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/mod.rs @@ -138,6 +138,21 @@ impl PluginBuilder { Ok(self) } + /// 冻结模型别名目录;目标校验与发布由宿主负责。 + /// + /// # Errors + /// + /// 未声明模型目录能力或重复注册时失败。 + pub fn model_catalog( + self, + registration: crate::call::catalog::ModelCatalogRegistration, + ) -> Result { + self.on(methods::MODEL_CATALOG_REGISTER, move |_| { + let registration = registration.clone(); + async move { Ok(TypedReply::new(registration)) } + }) + } + /// 冻结管理页面/路由描述并绑定类型化业务函数。 /// /// # Errors 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 eaa77735a..09e1aeb43 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs @@ -18,6 +18,8 @@ pub(super) fn validate( match capability { C::Middleware => required(crate::call::middleware::HANDLE_METHOD)?, C::ModelRouter => required(methods::ROUTE_MODEL.name)?, + 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)?, diff --git a/backend/crates/gateway-plugin/sdk/src/manifest.rs b/backend/crates/gateway-plugin/sdk/src/manifest.rs index f98215395..9c6e0e52f 100644 --- a/backend/crates/gateway-plugin/sdk/src/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/src/manifest.rs @@ -261,7 +261,8 @@ impl Manifest { let stages = declaration.stages.iter().copied().collect::>(); let input_formats = declaration.input_formats.iter().collect::>(); let output_formats = declaration.output_formats.iter().collect::>(); - if declaration.version != 1 + if !(declaration.version == 1 + || (*capability == Capability::Middleware && declaration.version == 2)) || declaration.id.len() > 128 || local_id.is_empty() || !local_id.is_ascii() diff --git a/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs b/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs index d1eb1405c..c1bf4c67b 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs @@ -67,6 +67,7 @@ fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() protocol: None, header_mutations: Vec::new(), body: MiddlewareRequestBody::Preserve, + capabilities: None, }; assert_eq!( serde_json::to_value(preserve).unwrap(), @@ -85,6 +86,7 @@ fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() }, ], body: MiddlewareRequestBody::Replace, + capabilities: None, }; assert_eq!( serde_json::to_value(replace).unwrap(), 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 0f9e81d35..600df7f14 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs @@ -203,6 +203,92 @@ async fn composed_plugin_registers_and_dispatches_multiple_typed_entries() { shutdown(&mut host, task).await; } +#[tokio::test] +async fn catalog_and_retry_entries_use_typed_registration_and_stage_validation() { + use gateway_plugin_sdk::call::{ + catalog::{ModelAlias, ModelCatalogRegistration}, + policy::{RetryAction, RetryDecision, RetryDecisionRequest}, + }; + let source = author_manifest(json!({"model_catalog":{},"retry_policy":{}}), json!([])); + let manifest = gateway_plugin_sdk::Manifest::from_author_slice(&source).unwrap(); + let contributions = manifest.contributes.clone(); + let catalog = ModelCatalogRegistration { + models: vec![ModelAlias { + id: "public-model".into(), + provider: "openai".into(), + model: "upstream-model".into(), + }], + }; + let plugin = PluginBuilder::from_manifest(manifest) + .unwrap() + .model_catalog(catalog.clone()) + .unwrap() + .on(methods::RETRY_DECISION, |call| async move { + Ok(TypedReply::new( + if call.request.allowed_actions.contains(&RetryAction::Retry) { + RetryDecision::Retry + } else { + RetryDecision::Delegate + }, + )) + }) + .unwrap() + .build() + .unwrap(); + let (mut host, task) = start_session(plugin, contributions).await; + send_call( + &mut host, + 1, + "model_catalog.register", + Stage::Registration, + json!({}), + vec![], + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 1); + assert_eq!(result, json!({})); + assert_eq!( + serde_json::from_slice::(&payload).unwrap(), + catalog + ); + let request = serde_json::to_value(RetryDecisionRequest { + request_id: "req-example".into(), + attempt_index: 1, + provider: "openai".into(), + model: Some("upstream-model".into()), + error_kind: "rate_limited".into(), + upstream_status: Some(429), + send_state: gateway_plugin_sdk::SendState::NotSent, + remaining_routing_attempts: 1, + remaining_deadline_ms: 1_000, + allowed_actions: vec![RetryAction::Stop, RetryAction::Retry], + }) + .unwrap(); + send_call( + &mut host, + 3, + "policy.retry_decision", + Stage::Request, + request.clone(), + vec![], + ) + .await; + assert_invalid_input(receive(&mut host).await, 3); + send_call( + &mut host, + 5, + "policy.retry_decision", + Stage::Retry, + request, + vec![], + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 5); + assert_eq!(result, json!({"decision":"retry"})); + assert!(payload.is_empty()); + shutdown(&mut host, task).await; +} + #[tokio::test] async fn typed_dispatch_rejects_wrong_stage_and_payload_shape_before_handler() { let source = author_manifest(json!({"command_line": {}}), json!([])); diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session.rs b/backend/crates/gateway-plugin/sdk/tests/client/session.rs index 416e449d9..aefdcbfb7 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session.rs @@ -340,7 +340,7 @@ fn middleware_plugin_rejects_declarations_without_matching_handlers() { (Capability::Scheduler, declaration.clone()), ])); let mut unsupported = declaration.clone(); - unsupported.version = 2; + unsupported.version = 3; invalid.push(Contributions::from([(Capability::Middleware, unsupported)])); for stages in [ vec![], diff --git a/docs/api.md b/docs/api.md index 1b92ac0d7..39d94923b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1782,13 +1782,14 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta #### 访问域与受管资源 -清单的 `requestedPermissions` 只接受五个稳定访问域,确认结果以制品摘要为边界;实例输入没有单独授权字段。 +清单的 `requestedPermissions` 接受 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`,确认结果以制品摘要为边界;实例输入没有单独授权字段。 | 标识 | 含义 | | --- | --- | | `network` | 访问网络 | | `models` | 查询模型和 Client Key 基本信息并调用模型,可能产生消耗 | | `accounts` | 读取和修改账号,包括访问原始凭据 | +| `data` | 仅在管理或命令入口只读全部账号的基础信息和已有额度观测,不含凭据或预测 | | `requests` | 查看和处理请求、响应、路由及账号选择 | | `public_endpoints` | 提供无需登录即可访问的资源与回调入口 | diff --git a/docs/architecture.md b/docs/architecture.md index 1d987a006..fb11c4891 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,14 @@ flowchart LR #### 发布与执行边界 -- **制品接受固定访问域。** 只读校验不持久化;上传、URL 和 GitHub 的确认安装接受精确摘要声明的五个访问域, +- **模型目录与路由共用事实。** `model_catalog` 的直接别名保存在发布代次引用中,Core 校验原生模型、静态映射与插件 ID 冲突及目标存在性后发布。 + 公开列表、详情、原生目录和受管模型查询复用相同别名与账号访问范围,不另维护插件专用目录。 +- **重试由 Core 裁决。** `retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。 + 发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核。 +- **转换声明不替代正文事实。** middleware v2 在 request 阶段声明具体承担的功能和额外上游需求,原始需求保留用于诊断,实际正文与未承担的原始功能仍参与选路。 + attempt 阶段拒绝需求声明。没有改写的正文、header 和响应帧在相同网关边界保留原字节与顺序。 + +- **制品接受固定访问域。** 只读校验不持久化;上传、URL 和 GitHub 的确认安装接受精确摘要声明的访问域, 官方发行导入仍须管理员确认。若插件尚无实例,Admin 以稳定 ID 合并 schema 默认值与适用贡献项 binding: 配置完整则启用,缺少必填项则停用待补;已有实例时不复制配置或切换版本。实例不能增删制品访问域。 - **一份发布快照。** 数据面和管理页面使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。 @@ -131,10 +138,11 @@ flowchart LR 正文按需读取,透传无需额外读取权限;转换和改写不能绕过 Core 的交付、取消与结算边界。 数据面插件通过受管 HTTP 已发送或无法证明未发送时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 Provider 的 `not_sent` 结果透明重放。 -- **访问域不跨调用。** `network`、`models`、`accounts`、`requests`、`public_endpoints` 只开放对应资源域; +- **访问域不跨调用。** `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints` 只开放对应资源域; 账号、HTTP、模型、私有状态和日志回调仍绑定有效父调用与阶段。管理页和 CLI 不预绑定 Client Key,模型调用按次 选择当前 Key;页面 Responses 桥还复核精确实例目标和 `models` 域,并在等待与交付期间持续撤销检查。 `frontend_authentication` 必须显式配置 principal 到 Key 的映射;公开登录回调使用一次性票据且不继承宿主回调权限。 + `data` 仅向管理和命令阶段提供账号基础投影与已有额度观测,不提供凭据、预测或 SQL,不进入客户端请求链。 - **版本切换保持数据一致。** 同一插件最多启用一个实例;每个实例与制品摘要保存最近启用时提交的配置、密钥和绑定, 快照与实例修改共享事务,停用草稿不覆盖恢复点。版本切换优先恢复目标快照,否则合并缺失默认值并保留显式设置。 私有状态按实例、schema 与配置版本隔离,写入使用精确记录版本。 diff --git a/docs/plugins.md b/docs/plugins.md index 68f2e3cc3..d0d305388 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -40,7 +40,7 @@ flowchart LR 类型错误、非法 schema 或把敏感值混入普通配置会直接拒绝,不会伪装成“待配置”。更新已有插件时不会创建另一套配置, 管理端在包安装完成后提示确认切换,检查通过后应用目标版本,不兼容时保留原版本并打开待调整的设置。 -### 五个访问域 +### 访问域 安装确认只使用以下稳定访问域;具体调用仍受贡献项、当前调用上下文和宿主策略限制。 @@ -49,6 +49,7 @@ flowchart LR | `network` | 访问网络 | | `models` | 查询模型和 Client Key 基本信息并调用模型,可能产生消耗 | | `accounts` | 读取和修改账号,包括访问原始凭据 | +| `data` | 仅在管理或命令入口只读全部账号的基础信息和已有额度观测,不含凭据或预测 | | `requests` | 查看和处理请求、响应、路由及账号选择 | | `public_endpoints` | 提供无需登录即可访问的资源与回调入口 | @@ -81,7 +82,9 @@ flowchart LR | --- | --- | | 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 | | 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE | -| 请求中间件、模型路由、账号调度、请求观察 | 匹配功能范围的正常网关请求自动使用 | +| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 | +| 模型目录 | 启用后原子发布别名,列表与路由继承目标模型的能力和现有访问范围 | +| 基础数据页面 | 通过 `data` 只读账号基础信息与已有额度观测,展示本身不进入请求改写链 | | 入口认证 | 客户端按插件协议访问,宿主把明确的 principal 映射到 Client Key | | 命令行 | 在宿主环境运行 `codex-proxy-rs plugin <配置 ID> --help` | diff --git a/frontend/src/views/plugins/components/PluginBindingEditor.vue b/frontend/src/views/plugins/components/PluginBindingEditor.vue index 7c4dc9688..af89cf96e 100644 --- a/frontend/src/views/plugins/components/PluginBindingEditor.vue +++ b/frontend/src/views/plugins/components/PluginBindingEditor.vue @@ -14,11 +14,12 @@ const stages: Record = { middleware: ['request', 'attempt'], model_router: ['routing'], scheduler: ['scheduling'], + retry_policy: ['retry'], request_lifecycle: ['observation'], usage: ['observation'], web_socket_observer: ['observation'], } -const stageLabels: Record = { request: '请求开始', attempt: '每次尝试', routing: '模型路由', scheduling: '账号调度', observation: '请求结束' } +const stageLabels: Record = { request: '请求开始', attempt: '每次尝试', 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}` })), @@ -44,7 +45,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' : 'reject', providerIds: [], models: [], clientKeyIds: [], accountGroupIds: [], identityBindings: [] }] + 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: [] }] } } function setGlobal(key: string, value: boolean) { @@ -107,7 +108,7 @@ watch(valid, value => emit('validityChange', value), { immediate: true }) - + diff --git a/frontend/src/views/plugins/components/PluginConfigurationModal.vue b/frontend/src/views/plugins/components/PluginConfigurationModal.vue index a24fb0d61..49879edb3 100644 --- a/frontend/src/views/plugins/components/PluginConfigurationModal.vue +++ b/frontend/src/views/plugins/components/PluginConfigurationModal.vue @@ -37,7 +37,7 @@ const authenticationBinding = computed({ bindings.value = binding ? [...others, cloneJsonValue(binding)] : others }, }) -const hasRequestBindings = computed(() => Object.values(props.artifact?.metadata.contributes ?? {}).some(contribution => contribution.stages.some(stage => ['request', 'attempt', 'routing', 'scheduling', 'observation'].includes(stage)))) +const hasRequestBindings = computed(() => Object.values(props.artifact?.metadata.contributes ?? {}).some(contribution => contribution.stages.some(stage => ['request', 'attempt', 'routing', 'scheduling', 'retry', 'observation'].includes(stage)))) const sections = computed(() => [ { label: '插件参数', value: 'general', icon: Settings2 }, ...(hasRequestBindings.value ? [{ label: '高级设置', value: 'requests', icon: Blocks }] : []), diff --git a/frontend/src/views/plugins/components/PluginConfigurationSummary.vue b/frontend/src/views/plugins/components/PluginConfigurationSummary.vue index 2dcd80a77..2b1f2305e 100644 --- a/frontend/src/views/plugins/components/PluginConfigurationSummary.vue +++ b/frontend/src/views/plugins/components/PluginConfigurationSummary.vue @@ -16,13 +16,14 @@ const stageLabels: Record = { attempt: '每次尝试', routing: '模型路由', scheduling: '账号调度', + retry: '重试决策', observation: '请求观察', management: '管理操作', execution: '模型执行', maintenance: '后台维护', authentication: '客户端认证', } -const requestStages = new Set(['request', 'attempt', 'routing', 'scheduling', 'observation']) +const requestStages = new Set(['request', 'attempt', 'routing', 'scheduling', 'retry', 'observation']) const groups = computed(() => { const result = new Map() for (const binding of props.instance.bindings) { diff --git a/frontend/src/views/plugins/components/PluginPermissionSummary.vue b/frontend/src/views/plugins/components/PluginPermissionSummary.vue index cde1467a7..7daf92e01 100644 --- a/frontend/src/views/plugins/components/PluginPermissionSummary.vue +++ b/frontend/src/views/plugins/components/PluginPermissionSummary.vue @@ -1,6 +1,6 @@