From b920dcec835fb44ed696afe8595ad232575f7f13 Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 25 Sep 2026 22:52:59 +0800 Subject: [PATCH] feat(plugin): extend model, data and retry capabilities Add model aliases, minimal read-only account and quota facts, bounded retry policies and middleware capability declarations. Preserve unchanged payloads and expose the new contracts in the SDK and plugin configuration UI. Remove the ignored external observer example test. Co-authored-by: Codex --- .agents/skills/plugin-dev/SKILL.md | 5 +- .../src/ports/plugin_accounts.rs | 8 + .../src/use_case/plugin_accounts.rs | 14 + .../src/use_case/plugins/artifacts.rs | 4 +- .../crates/gateway-api/src/openai/endpoint.rs | 4 +- .../gateway-api/src/openai/responses/http.rs | 5 +- .../src/openai/responses/request.rs | 8 + .../src/openai/responses/websocket/mod.rs | 6 +- .../gateway-core/src/engine/coordinator.rs | 112 ++++- .../gateway-core/src/engine/middleware.rs | 94 ++++- .../crates/gateway-core/src/engine/policy.rs | 57 +++ .../gateway-core/src/engine/provider.rs | 3 + backend/crates/gateway-core/src/lib.rs | 5 +- .../crates/gateway-core/src/operation/mod.rs | 32 ++ .../gateway-core/src/routing/catalog.rs | 9 + .../crates/gateway-core/src/routing/mod.rs | 6 +- .../gateway-core/src/routing/snapshot.rs | 83 +++- .../gateway-core/src/runtime/extensions.rs | 14 +- .../gateway-core/tests/engine/execution.rs | 193 ++++++++- .../gateway-core/tests/engine/middleware.rs | 132 ++++++ .../gateway-core/tests/routing/snapshot.rs | 134 +++++- .../runtime/plugin-host-compatibility.json | 5 +- .../runtime/src/adapter/catalog.rs | 78 ++++ .../gateway-plugin/runtime/src/adapter/mod.rs | 1 + .../runtime/src/adapter/policy/middleware.rs | 8 +- .../runtime/src/adapter/policy/mod.rs | 64 ++- .../runtime/src/adapter/policy/retry.rs | 107 +++++ .../src/adapter/policy/route_schedule.rs | 8 + .../runtime/src/callback/data.rs | 119 ++++++ .../runtime/src/callback/middleware/mod.rs | 58 ++- .../runtime/src/callback/mod.rs | 11 + .../src/generation/prepare/instance.rs | 3 + .../runtime/src/generation/prepare/mod.rs | 37 ++ .../runtime/src/rpc/dispatch.rs | 9 + .../runtime/tests/generation/data.rs | 213 ++++++++++ .../runtime/tests/generation/mod.rs | 12 +- .../tests/generation/observer_example.rs | 286 ------------- .../runtime/tests/generation/policy.rs | 385 +++++++++++++++++- .../runtime/tests/generation/prepare/mod.rs | 114 ++++++ .../runtime/tests/package/compatibility.rs | 7 +- .../runtime/tests/rpc/dispatch.rs | 55 +++ .../runtime/tests/support/middleware.rs | 79 +++- .../runtime/tests/support/mod.rs | 2 + .../runtime/tests/support/worker.rs | 54 +++ backend/crates/gateway-plugin/sdk/README.md | 4 +- .../gateway-plugin/sdk/docs/capabilities.md | 107 ++++- .../gateway-plugin/sdk/docs/manifest.md | 8 +- .../gateway-plugin/sdk/src/call/catalog.rs | 19 + .../gateway-plugin/sdk/src/call/data.rs | 58 +++ .../gateway-plugin/sdk/src/call/middleware.rs | 20 + .../crates/gateway-plugin/sdk/src/call/mod.rs | 2 + .../gateway-plugin/sdk/src/call/policy/mod.rs | 32 ++ .../gateway-plugin/sdk/src/capability.rs | 16 +- .../gateway-plugin/sdk/src/client/data.rs | 51 +++ .../sdk/src/client/middleware/body.rs | 19 + .../sdk/src/client/middleware/mod.rs | 11 + .../sdk/src/client/middleware/plugin.rs | 2 +- .../gateway-plugin/sdk/src/client/mod.rs | 1 + .../sdk/src/client/plugin/methods.rs | 18 +- .../sdk/src/client/plugin/mod.rs | 15 + .../sdk/src/client/plugin/validation.rs | 2 + .../crates/gateway-plugin/sdk/src/manifest.rs | 3 +- .../sdk/tests/call/middleware.rs | 2 + .../sdk/tests/client/plugin/mod.rs | 86 ++++ .../sdk/tests/client/session.rs | 2 +- docs/api.md | 3 +- docs/architecture.md | 12 +- docs/plugins.md | 7 +- .../components/PluginBindingEditor.vue | 7 +- .../components/PluginConfigurationModal.vue | 2 +- .../components/PluginConfigurationSummary.vue | 3 +- .../components/PluginPermissionSummary.vue | 3 +- frontend/src/views/plugins/utils/model.ts | 2 + 73 files changed, 2781 insertions(+), 379 deletions(-) create mode 100644 backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/callback/data.rs create mode 100644 backend/crates/gateway-plugin/runtime/tests/generation/data.rs delete mode 100644 backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs create mode 100644 backend/crates/gateway-plugin/sdk/src/call/catalog.rs create mode 100644 backend/crates/gateway-plugin/sdk/src/call/data.rs create mode 100644 backend/crates/gateway-plugin/sdk/src/client/data.rs 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 @@