diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 97c3cfa55..887d75e6d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1776,6 +1776,7 @@ dependencies = [ "thiserror", "tokio", "tokio-rustls", + "tokio-tungstenite 0.29.0", "tower-service", "tracing", "tracing-appender", @@ -1813,6 +1814,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tokio-tungstenite 0.29.0", "tracing", "url", "uuid", diff --git a/backend/crates/gateway-core/src/engine/continuation.rs b/backend/crates/gateway-core/src/engine/continuation.rs index f95fc488a..be11f80da 100644 --- a/backend/crates/gateway-core/src/engine/continuation.rs +++ b/backend/crates/gateway-core/src/engine/continuation.rs @@ -61,7 +61,7 @@ pub struct NativeContinuationPin { provider: ProviderKind, account: ProviderAccountId, scope: NativeContinuationScope, - session_state: Option, + session_state: Option>, } impl NativeContinuationPin { @@ -94,7 +94,7 @@ impl NativeContinuationPin { /// 附着仅由对应 Provider 解释的不透明会话状态。 #[must_use] pub fn with_session_state(mut self, state: ProviderSessionState) -> Self { - self.session_state = Some(state); + self.session_state = Some(Box::new(state)); self } @@ -136,8 +136,8 @@ impl NativeContinuationPin { /// 返回与本 pin 同账号绑定的 Provider 私有会话状态。 #[must_use] - pub const fn session_state(&self) -> Option<&ProviderSessionState> { - self.session_state.as_ref() + pub fn session_state(&self) -> Option<&ProviderSessionState> { + self.session_state.as_deref() } /// 校验本次 route/account 选择没有破坏 native pin。 diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index eea0f46ad..26ef5b65c 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -59,6 +59,7 @@ pub(super) struct CoordinationExtensions { execution_effects: Option>, execution_effects_baseline: usize, middleware: Option, + upstream_adapters: Option, account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, endpoint: String, client_transport: super::execution::ClientTransport, @@ -66,6 +67,14 @@ pub(super) struct CoordinationExtensions { } impl CoordinationExtensions { + pub(super) fn with_upstream_adapters( + mut self, + plan: Option, + ) -> Self { + self.upstream_adapters = plan; + self + } + pub(super) fn with_response_control( mut self, control: Option, @@ -86,6 +95,7 @@ impl CoordinationExtensions { execution_effects: None, execution_effects_baseline: 0, middleware: None, + upstream_adapters: None, account_group_ids: Arc::from([]), endpoint: String::new(), client_transport: super::execution::ClientTransport::InternalProbe, @@ -247,6 +257,7 @@ where execution_effects, execution_effects_baseline, middleware, + upstream_adapters, account_group_ids, endpoint, client_transport, @@ -304,6 +315,7 @@ where .unwrap_or(Duration::ZERO), ) .fuse(), + requested_model: request.requested_model.clone(), pending_request: Some(request), request_persisted: false, response_control, @@ -313,6 +325,7 @@ where execution_effects, execution_effects_baseline, middleware, + upstream_adapters, account_group_ids, endpoint, client_transport, @@ -420,6 +433,7 @@ pub struct ResponseExecutionSession { /// 会话级 deadline 计时器;deadline 固定,帧循环内复用而非逐事件新建。 deadline_timer: Fuse, pending_request: Option, + requested_model: Option, request_persisted: bool, response_control: Option, operation: Operation, @@ -428,6 +442,7 @@ pub struct ResponseExecutionSession { execution_effects: Option>, execution_effects_baseline: usize, middleware: Option, + upstream_adapters: Option, account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, endpoint: String, client_transport: super::execution::ClientTransport, @@ -756,7 +771,16 @@ where provider, account, ) - .with_scope(NativeContinuationScope::Persisted) + .with_scope( + if state + .extension_owner() + .is_some_and(|owner| owner.connection_local) + { + NativeContinuationScope::ConnectionLocal + } else { + NativeContinuationScope::Persisted + }, + ) .with_session_state(state.clone()), ) } @@ -1019,6 +1043,8 @@ where .with_timing_started_at(self.observation.timing_started_at) .with_request_policy(self.request_policy.clone()) .with_execution_effects(self.execution_effects.as_ref().map(Arc::clone)) + .with_upstream_adapters(self.upstream_adapters.clone()) + .with_requested_model(self.requested_model.clone()) .with_middleware( self.middleware.clone(), Arc::clone(&self.account_group_ids), diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index b38ed57f7..4f36000d8 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -973,6 +973,11 @@ impl DefaultExecutionService { .iter() .map(|group| group.id().clone()) .collect::>(); + let upstream_adapters = request + .client + .snapshot + .extensions() + .and_then(|set| set.upstream_adapters()); let middleware = self.middlewares.as_ref().and_then(|middlewares| { let generation = request.client.snapshot.extensions()?.clone(); middlewares.resolve(&generation) @@ -1167,6 +1172,7 @@ impl DefaultExecutionService { CoordinationExtensions::new(continuation, request_observation.clone()) .with_response_control(authorization.response_control.clone()) .with_request_policy(request_policy) + .with_upstream_adapters(upstream_adapters) .with_execution_effects( execution_effects, authorization.execution_effects_baseline, diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index df46ff907..19668a942 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -15,6 +15,7 @@ pub mod policy; pub mod probe; pub mod provider; pub mod response_control; +pub mod upstream_adapter; pub use coordinator::{AttemptCoordinator, ResponseExecutionSession}; @@ -338,6 +339,8 @@ pub struct RequestAttemptContext { request_policy: Option, execution_effects: Option>, middleware: Option, + upstream_adapters: Option, + requested_model: Option, account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, endpoint: String, client_transport: execution::ClientTransport, @@ -345,6 +348,21 @@ pub struct RequestAttemptContext { } impl RequestAttemptContext { + #[must_use] + pub fn with_requested_model(mut self, model: Option) -> Self { + self.requested_model = model; + self + } + + #[must_use] + pub fn with_upstream_adapters( + mut self, + plan: Option, + ) -> Self { + self.upstream_adapters = plan; + self + } + #[must_use] pub fn with_response_control( mut self, @@ -401,6 +419,8 @@ impl RequestAttemptContext { request_policy: None, execution_effects: None, middleware: None, + upstream_adapters: None, + requested_model: None, account_group_ids: Arc::from([]), endpoint: String::new(), client_transport: execution::ClientTransport::InternalProbe, @@ -539,6 +559,38 @@ pub struct AttemptContext { } impl AttemptContext { + #[must_use] + pub fn execution_effects(&self) -> Option> { + self.request.execution_effects.as_ref().map(Arc::clone) + } + + #[must_use] + pub const fn requested_model(&self) -> Option<&PublicModelId> { + self.request.requested_model.as_ref() + } + + /// 从本次请求冻结的发布代次选择上游适配器;不建立连接、不重新选号。 + pub fn upstream_adapter( + &self, + provider: &ProviderKind, + model: &UpstreamModelId, + ) -> Result>, ProviderError> { + self.request + .upstream_adapters + .as_ref() + .map_or(Ok(None), |plan| plan.select(self, provider, model)) + } + + #[must_use] + pub fn account_group_ids(&self) -> &[crate::account::scope::AccountGroupId] { + &self.request.account_group_ids + } + + #[must_use] + pub const fn client_transport(&self) -> execution::ClientTransport { + self.request.client_transport + } + #[must_use] pub fn response_control(&self) -> Option<&response_control::ResponseControl> { self.request.response_control.as_ref() diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index d988aaa53..227fe3d1d 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -22,7 +22,9 @@ use crate::engine::middleware::{ MiddlewareHeader, MiddlewareNext, MiddlewareRequest, MiddlewareResponse, }; use crate::error::{PreDeliveryRetry, ProviderError, ProviderErrorKind}; -use crate::event::{EventSequenceValidator, GatewayEvent, ProtocolWireEvent, ProviderEvent}; +use crate::event::{ + EventSequenceError, EventSequenceValidator, GatewayEvent, ProtocolWireEvent, ProviderEvent, +}; use crate::identity::ProviderKind; use crate::operation::Operation; use crate::policy::ClientApiKeyId; @@ -216,7 +218,7 @@ pub struct ProviderStream { _lease: Box, native_response_translator: Option>, account_feedback: Option, - validator: EventSequenceValidator, + validator: Result, strict_canonical_seen: bool, terminated: bool, } @@ -323,7 +325,7 @@ impl ProviderStream { _lease: Box::new(lease), native_response_translator: None, account_feedback: None, - validator: EventSequenceValidator::new(), + validator: Ok(EventSequenceValidator::new()), strict_canonical_seen: false, terminated: false, } @@ -857,24 +859,26 @@ impl Stream for ProviderStream { match this.events.as_mut().poll_next(context) { Poll::Pending => Poll::Pending, Poll::Ready(Some(Ok(event))) => { - // 带 wire 的 canonical facts 只是旁路观测:wire 才是客户端协议的 - // 权威表达。只有 canonical-only Provider 输出需要以状态机作为交付条件。 - if event.wire_event().is_none() && !event.canonical_facts().is_empty() { - this.strict_canonical_seen = true; - for fact in event.canonical_facts() { - if this.validator.observe(fact).is_err() { - this.terminated = true; - let error = ProviderError::new( - ProviderErrorKind::Protocol, - UpstreamSendState::Sent, - ); - if let Some(feedback) = this.account_feedback.as_mut() { - feedback.report_failure(&error); - } - return Poll::Ready(Some(Err(error))); - } + // 纯 wire 流的 facts 仍只是旁路观测;一旦交付 canonical-only + // 事件,就必须校验完整事实序列,不能漏掉带 wire 的开始或终态。 + this.strict_canonical_seen |= + event.wire_event().is_none() && !event.canonical_facts().is_empty(); + for fact in event.canonical_facts() { + if let Ok(validator) = &mut this.validator + && let Err(error) = validator.observe(fact) + { + this.validator = Err(error); } } + if this.strict_canonical_seen && this.validator.is_err() { + this.terminated = true; + let error = + ProviderError::new(ProviderErrorKind::Protocol, UpstreamSendState::Sent); + if let Some(feedback) = this.account_feedback.as_mut() { + feedback.report_failure(&error); + } + return Poll::Ready(Some(Err(error))); + } if let Some(feedback) = this.account_feedback.as_mut() { feedback.observe(&event); } @@ -890,7 +894,10 @@ impl Stream for ProviderStream { Poll::Ready(None) => { this.terminated = true; let validation = if this.strict_canonical_seen { - this.validator.finish() + this.validator + .as_ref() + .map_err(Clone::clone) + .and_then(EventSequenceValidator::finish) } else { Ok(()) }; diff --git a/backend/crates/gateway-core/src/engine/upstream_adapter.rs b/backend/crates/gateway-core/src/engine/upstream_adapter.rs new file mode 100644 index 000000000..30e8bdb24 --- /dev/null +++ b/backend/crates/gateway-core/src/engine/upstream_adapter.rs @@ -0,0 +1,99 @@ +//! 内置 Provider 已选账号上的受管上游适配端口。 +//! +//! 适配器不注册 Provider 或选择账号。Provider 持有租约、解释凭据并计算费用; +//! Runtime 只实现适配器与受管网络之间的连接,返回值继续经过原生事件和结算链。 + +use std::{fmt, sync::Arc}; + +use futures::future::BoxFuture; + +use crate::{ + account::{CredentialRevision, OutboundProxy, ProviderAccountId}, + error::ProviderError, + identity::ProviderKind, + metering::{CalculatedCost, Usage}, + operation::Operation, + routing::UpstreamModelId, + runtime::extensions::ExtensionSetReference, +}; + +use super::{ + AttemptContext, + middleware::MiddlewareHeader, + provider::{EventStream, ProviderCallMetadata}, +}; + +/// 发布代次内冻结的适配器选择计划;匹配只读宿主身份,不调用插件进程。 +pub trait UpstreamAdapterPlan: fmt::Debug + Send + Sync { + fn select( + &self, + context: &AttemptContext, + provider: &ProviderKind, + model: &UpstreamModelId, + ) -> Result>, ProviderError>; +} + +/// 请求同时持有计划及发布集合,防止旧集合先排空进程而中断在途调用。 +#[derive(Debug, Clone)] +pub struct FrozenUpstreamAdapterPlan { + plan: Arc, + _generation: ExtensionSetReference, +} + +impl FrozenUpstreamAdapterPlan { + #[must_use] + pub const fn new( + plan: Arc, + generation: ExtensionSetReference, + ) -> Self { + Self { + plan, + _generation: generation, + } + } + + pub(super) fn select( + &self, + context: &AttemptContext, + provider: &ProviderKind, + model: &UpstreamModelId, + ) -> Result>, ProviderError> { + self.plan.select(context, provider, model) + } +} + +/// 为本次调用选定的适配器,持有对应代次的运行资源。 +pub trait UpstreamAdapter: fmt::Debug + Send + Sync { + /// 返回冷流。首次消费前不得调用插件、建立业务连接或发送请求。 + fn execute(self: Arc, invocation: UpstreamAdapterInvocation) -> EventStream; + + /// 注册时声明的上游传输,用于宿主登记 attempt。 + fn transport(&self) -> &str; +} + +/// 一次执行的宿主事实;账号及 Provider 归属不可由插件返回值覆盖。 +pub struct UpstreamAdapterInvocation { + pub operation: Operation, + pub headers: Vec, + pub context: AttemptContext, + pub metadata: ProviderCallMetadata, + pub account: Arc, +} + +/// Provider 针对已选账号租约提供的凭据与反馈端口。 +/// +/// 实现必须持有原账号租约。鉴权头只交给宿主网络边界,不能投影到插件 RPC。 +/// 目标授权由适配器注册与受管网络检查,凭据格式及刷新协议仍归 Provider。 +pub trait UpstreamAccountConnection: Send + Sync { + fn account_id(&self) -> &ProviderAccountId; + fn credential_revision(&self) -> CredentialRevision; + fn authentication_kind(&self) -> &str; + fn outbound_proxy(&self) -> Option<&OutboundProxy>; + fn authorization(&self) -> Result, ProviderError>; + + /// 复用 Provider 的价格来源;未知用量、模型或档位不构造零费用。 + fn calculate_cost(&self, service_tier: Option<&str>, usage: &Usage) -> Option; + + /// 失败的刷新、账号状态和冷却反馈仍由 Provider 处理,不由 Runtime 写账号。 + fn record_failure(&self, error: ProviderError) -> BoxFuture<'_, ProviderError>; +} diff --git a/backend/crates/gateway-core/src/operation/mod.rs b/backend/crates/gateway-core/src/operation/mod.rs index abfe31738..97986853b 100644 --- a/backend/crates/gateway-core/src/operation/mod.rs +++ b/backend/crates/gateway-core/src/operation/mod.rs @@ -120,6 +120,20 @@ impl CapabilityRequirements { pub struct ProviderSessionState { provider: String, payload: Map, + #[serde(default, skip_serializing_if = "Option::is_none")] + extension_owner: Option>, +} + +/// 受管扩展会话的宿主归属,不能把插件私有状态交给原生 Provider 解码。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExtensionSessionOwner { + pub instance_id: String, + pub contribution_id: String, + pub adapter_id: String, + pub generation: u64, + pub incarnation: String, + pub connection_local: bool, } impl ProviderSessionState { @@ -136,7 +150,11 @@ impl ProviderSessionState { validate_text(&provider, 64, true, None).map_err(|_| OperationError::EmptyField { field: "provider_session_state provider", })?; - Ok(Self { provider, payload }) + Ok(Self { + provider, + payload, + extension_owner: None, + }) } #[must_use] @@ -148,6 +166,17 @@ impl ProviderSessionState { pub const fn payload(&self) -> &Map { &self.payload } + + #[must_use] + pub fn with_extension_owner(mut self, owner: ExtensionSessionOwner) -> Self { + self.extension_owner = Some(Box::new(owner)); + self + } + + #[must_use] + pub fn extension_owner(&self) -> Option<&ExtensionSessionOwner> { + self.extension_owner.as_deref() + } } impl fmt::Debug for ProviderSessionState { diff --git a/backend/crates/gateway-core/src/runtime/extensions.rs b/backend/crates/gateway-core/src/runtime/extensions.rs index 7875b5f1d..0e1f52c4a 100644 --- a/backend/crates/gateway-core/src/runtime/extensions.rs +++ b/backend/crates/gateway-core/src/runtime/extensions.rs @@ -32,6 +32,13 @@ impl ExtensionSetId { pub trait ExtensionSetLease: Send + Sync { fn is_ready(&self) -> bool; + /// 与发布集合一起冻结的上游适配计划,不能在请求中解析为新代次。 + fn upstream_adapters( + &self, + ) -> Option> { + None + } + /// 与集合一起冻结的目录事实,读取不调用插件进程。 fn model_aliases(&self) -> &[ContributedModelAlias] { &[] @@ -50,6 +57,15 @@ pub struct ExtensionSetReference { } impl ExtensionSetReference { + #[must_use] + pub fn upstream_adapters( + &self, + ) -> Option { + self.lease.upstream_adapters().map(|plan| { + crate::engine::upstream_adapter::FrozenUpstreamAdapterPlan::new(plan, self.clone()) + }) + } + #[must_use] pub fn new(id: ExtensionSetId, lease: Arc) -> Self { Self { id, lease } diff --git a/backend/crates/gateway-core/tests/engine/provider.rs b/backend/crates/gateway-core/tests/engine/provider.rs index e43edc54b..b6fb5bca1 100644 --- a/backend/crates/gateway-core/tests/engine/provider.rs +++ b/backend/crates/gateway-core/tests/engine/provider.rs @@ -276,6 +276,80 @@ fn provider_stream_should_not_sequence_gate_canonical_observation_attached_to_wi assert!(futures::executor::block_on(provider_stream.next()).is_none()); } +#[test] +fn provider_stream_should_validate_complete_facts_across_mixed_wire_events() { + for wire_started in [false, true] { + let metadata = ProviderCallMetadata::new( + ProviderKind::new("openai").unwrap(), + UpstreamModelId::new("gpt-5").unwrap(), + ProviderAccountId::new("acct_mixed").unwrap(), + UpstreamTransport::new("http_json").unwrap(), + ); + let response = ResponseMeta::new("response-mixed", "gpt-5"); + let wire = || ProtocolWireEvent::json("openai", None, json!({})).unwrap(); + let started = GatewayEvent::Started(response.clone()); + let started = if wire_started { + ProviderEvent::canonical_with_wire(vec![started], wire()) + } else { + ProviderEvent::canonical(started) + }; + let events = vec![ + Ok(started), + Ok(ProviderEvent::canonical(GatewayEvent::Usage( + gateway_core::metering::Usage { + input_tokens: Some(7), + ..Default::default() + }, + ))), + Ok(ProviderEvent::canonical_with_wire( + vec![GatewayEvent::Completed(response)], + wire(), + )), + ]; + let mut provider = ProviderStream::new(metadata, Box::pin(stream::iter(events)), ()); + futures::executor::block_on(async { + for _ in 0..3 { + assert!(provider.next().await.unwrap().is_ok()); + } + assert!(provider.next().await.is_none()); + }); + } +} + +#[test] +fn provider_stream_should_reject_invalid_wire_facts_when_canonical_output_is_used() { + for invalid_wire_first in [false, true] { + let metadata = ProviderCallMetadata::new( + ProviderKind::new("openai").unwrap(), + UpstreamModelId::new("gpt-5").unwrap(), + ProviderAccountId::new("acct_mixed").unwrap(), + UpstreamTransport::new("http_json").unwrap(), + ); + let started = GatewayEvent::Started(ResponseMeta::new("response-mixed", "gpt-5")); + let canonical = ProviderEvent::canonical(started.clone()); + let wire = ProviderEvent::canonical_with_wire( + vec![if invalid_wire_first { + GatewayEvent::Usage(Default::default()) + } else { + started + }], + ProtocolWireEvent::json("openai", None, json!({})).unwrap(), + ); + let events = if invalid_wire_first { + vec![Ok(wire), Ok(canonical)] + } else { + vec![Ok(canonical), Ok(wire)] + }; + let mut provider = ProviderStream::new(metadata, Box::pin(stream::iter(events)), ()); + futures::executor::block_on(async { + assert!(provider.next().await.unwrap().is_ok()); + let error = provider.next().await.unwrap().unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Protocol); + assert!(provider.next().await.is_none()); + }); + } +} + #[test] fn provider_stream_should_report_confirmed_sent_failure_but_ignore_unconfirmed_failure() { let feedback = Arc::new(AccountFeedbackStats::default()); diff --git a/backend/crates/gateway-host/Cargo.toml b/backend/crates/gateway-host/Cargo.toml index 7312ad5c0..e7b7f81eb 100644 --- a/backend/crates/gateway-host/Cargo.toml +++ b/backend/crates/gateway-host/Cargo.toml @@ -40,6 +40,7 @@ tar = "0.4.44" thiserror = "2.0.18" tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "signal", "time", "sync", "net", "process", "io-util"] } tower-service = "0.3.3" +tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] } tracing = "0.1.44" tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } diff --git a/backend/crates/gateway-host/src/outbound/http.rs b/backend/crates/gateway-host/src/outbound/http.rs index 293e2505b..80e2f6cd8 100644 --- a/backend/crates/gateway-host/src/outbound/http.rs +++ b/backend/crates/gateway-host/src/outbound/http.rs @@ -30,6 +30,7 @@ struct ClientKey { addresses: Vec, proxy: Option, scope: Option, + websocket: bool, } pub struct HttpRequest { @@ -94,7 +95,7 @@ impl HttpError { self } - fn sent(reason: &'static str) -> Self { + pub(super) fn sent(reason: &'static str) -> Self { Self { reason, kind: HttpErrorKind::Response, @@ -139,7 +140,7 @@ impl HttpClient { return client.clone(); } let connector = Connector::new(key.addresses.clone(), key.proxy.clone(), self.tls.clone()); - let connector = connector::https(connector, self.tls.as_ref().clone(), true); + let connector = connector::https(connector, self.tls.as_ref().clone(), !key.websocket); let client = Client::builder(TokioExecutor::new()) .pool_max_idle_per_host(4) .http1_max_buf_size(64 * 1024) @@ -173,6 +174,21 @@ impl HttpClient { timeout: Duration, scope: Option<&str>, ) -> Result { + let response = self + .send(request, proxy, network, timeout, scope, None) + .await?; + Ok(response.into_http()) + } + + pub(super) async fn send( + &self, + request: HttpRequest, + proxy: Option<&OutboundProxy>, + network: &NetworkPolicy, + timeout: Duration, + scope: Option<&str>, + websocket_key: Option<&str>, + ) -> Result { if request.body.len() > 16 * 1024 * 1024 || request.headers.len() > 128 || timeout.is_zero() @@ -241,6 +257,7 @@ impl HttpClient { addresses: addresses.clone(), proxy: proxy.cloned(), scope: scope.map(str::to_owned), + websocket: websocket_key.is_some(), }); let mut uri: http::Uri = url .as_str() @@ -278,6 +295,18 @@ impl HttpClient { } } let mut outgoing = http::Request::new(Full::new(Bytes::from(request.body))); + if let Some(key) = websocket_key { + headers.insert( + http::header::CONNECTION, + HeaderValue::from_static("Upgrade"), + ); + headers.insert(http::header::UPGRADE, HeaderValue::from_static("websocket")); + headers.insert("sec-websocket-version", HeaderValue::from_static("13")); + headers.insert( + "sec-websocket-key", + HeaderValue::from_str(key).map_err(|_| HttpError::invalid("WebSocket key"))?, + ); + } *outgoing.method_mut() = method; *outgoing.uri_mut() = uri; *outgoing.headers_mut() = headers; @@ -311,17 +340,37 @@ impl HttpClient { { return Err(HttpError::sent("response headers")); } - Ok(HttpResponse { + Ok(PendingResponse { status, headers, + response, + permit, + deadline, + }) + } +} + +pub(super) struct PendingResponse { + pub(super) status: u16, + pub(super) headers: Vec<(String, Vec)>, + pub(super) response: http::Response, + pub(super) permit: OwnedSemaphorePermit, + pub(super) deadline: Instant, +} + +impl PendingResponse { + pub(super) fn into_http(self) -> HttpResponse { + HttpResponse { + status: self.status, + headers: self.headers, body: HttpBody { - response: response.into_body(), + response: self.response.into_body(), pending: Bytes::new(), received: 0, - deadline, - _permit: permit, + deadline: self.deadline, + _permit: self.permit, }, - }) + } } } diff --git a/backend/crates/gateway-host/src/outbound/mod.rs b/backend/crates/gateway-host/src/outbound/mod.rs index 1d4993755..eaedeab2b 100644 --- a/backend/crates/gateway-host/src/outbound/mod.rs +++ b/backend/crates/gateway-host/src/outbound/mod.rs @@ -3,6 +3,8 @@ mod connector; mod http; mod network; +mod websocket; pub use http::{HttpBody, HttpClient, HttpError, HttpErrorKind, HttpRequest, HttpResponse}; pub use network::{DnsResolver, NetworkPolicy}; +pub use websocket::{ManagedWebSocket, WebSocketMessage, WebSocketResponse}; diff --git a/backend/crates/gateway-host/src/outbound/websocket.rs b/backend/crates/gateway-host/src/outbound/websocket.rs new file mode 100644 index 000000000..8200f53a4 --- /dev/null +++ b/backend/crates/gateway-host/src/outbound/websocket.rs @@ -0,0 +1,166 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures::{SinkExt as _, StreamExt as _}; +use gateway_core::account::OutboundProxy; +use hyper_util::rt::TokioIo; +use tokio::sync::OwnedSemaphorePermit; +use tokio_tungstenite::{ + WebSocketStream, + tungstenite::{ + Message, + handshake::{client::generate_key, derive_accept_key}, + protocol::{Role, WebSocketConfig}, + }, +}; + +use super::{HttpBody, HttpClient, HttpError, HttpErrorKind, HttpRequest, NetworkPolicy}; + +const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024; + +/// 无内容型 Debug,连接由持有者独占;丢弃时关闭底层连接并释放容量。 +pub struct ManagedWebSocket { + socket: WebSocketStream>, + _permit: OwnedSemaphorePermit, +} + +pub enum WebSocketMessage { + Text(String), + Binary(Bytes), +} + +pub struct WebSocketResponse { + pub status: u16, + pub headers: Vec<(String, Vec)>, + pub connection: Option, + pub body: Option, +} + +impl HttpClient { + /// 使用与 HTTP 相同的 DNS、代理、证书和容量边界执行 HTTP/1.1 Upgrade。 + pub async fn open_websocket( + &self, + request: HttpRequest, + proxy: Option<&OutboundProxy>, + network: &NetworkPolicy, + timeout: Duration, + ) -> Result { + if request.method != "GET" + || !request.body.is_empty() + || request + .headers + .iter() + .any(|(name, _)| name.to_ascii_lowercase().starts_with("sec-websocket-")) + { + return Err(HttpError::invalid("WebSocket request")); + } + let key = generate_key(); + let mut response = self + .send(request, proxy, network, timeout, None, Some(&key)) + .await?; + if response.status != 101 { + let response = response.into_http(); + return Ok(WebSocketResponse { + status: response.status, + headers: response.headers, + connection: None, + body: Some(response.body), + }); + } + let headers = response.response.headers(); + if headers + .get("sec-websocket-accept") + .and_then(|value| value.to_str().ok()) + != Some(derive_accept_key(key.as_bytes()).as_str()) + || !headers + .get("upgrade") + .is_some_and(|value| value.as_bytes().eq_ignore_ascii_case(b"websocket")) + || !headers + .get("connection") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case("upgrade")) + }) + || headers.contains_key("sec-websocket-extensions") + || headers.contains_key("sec-websocket-protocol") + { + return Err(HttpError::sent("WebSocket handshake")); + } + let upgraded = tokio::time::timeout_at( + response.deadline, + hyper::upgrade::on(&mut response.response), + ) + .await + .map_err(|_| timeout_error())? + .map_err(|_| HttpError::sent("WebSocket upgrade"))?; + let config = WebSocketConfig::default() + .max_message_size(Some(MAX_MESSAGE_BYTES)) + .max_frame_size(Some(MAX_MESSAGE_BYTES)); + let socket = + WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Client, Some(config)) + .await; + Ok(WebSocketResponse { + status: response.status, + headers: response.headers, + connection: Some(ManagedWebSocket { + socket, + _permit: response.permit, + }), + body: None, + }) + } +} + +impl ManagedWebSocket { + pub async fn send( + &mut self, + message: WebSocketMessage, + timeout: Duration, + ) -> Result<(), HttpError> { + let message = match message { + WebSocketMessage::Text(text) if text.len() <= MAX_MESSAGE_BYTES => { + Message::Text(text.into()) + } + WebSocketMessage::Binary(bytes) if bytes.len() <= MAX_MESSAGE_BYTES => { + Message::Binary(bytes) + } + _ => return Err(HttpError::invalid("WebSocket message limits")), + }; + tokio::time::timeout(timeout, self.socket.send(message)) + .await + .map_err(|_| timeout_error())? + .map_err(|_| HttpError::sent("WebSocket send")) + } + + pub async fn read(&mut self, timeout: Duration) -> Result, HttpError> { + tokio::time::timeout(timeout, async { + loop { + match self.socket.next().await { + Some(Ok(Message::Text(text))) => { + return Ok(Some(WebSocketMessage::Text(text.to_string()))); + } + Some(Ok(Message::Binary(bytes))) => { + return Ok(Some(WebSocketMessage::Binary(bytes))); + } + Some(Ok(Message::Ping(_))) => self + .socket + .flush() + .await + .map_err(|_| HttpError::sent("WebSocket heartbeat"))?, + Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Ok(Message::Frame(_))) => return Err(HttpError::sent("WebSocket frame")), + Some(Err(_)) => return Err(HttpError::sent("WebSocket receive")), + } + } + }) + .await + .map_err(|_| timeout_error())? + } +} + +fn timeout_error() -> HttpError { + HttpError::sent("WebSocket deadline").with_kind(HttpErrorKind::Timeout) +} diff --git a/backend/crates/gateway-host/tests/outbound/mod.rs b/backend/crates/gateway-host/tests/outbound/mod.rs index 083c5c23e..a5270e370 100644 --- a/backend/crates/gateway-host/tests/outbound/mod.rs +++ b/backend/crates/gateway-host/tests/outbound/mod.rs @@ -1,3 +1,4 @@ mod connector; mod http; mod network; +mod websocket; diff --git a/backend/crates/gateway-host/tests/outbound/websocket.rs b/backend/crates/gateway-host/tests/outbound/websocket.rs new file mode 100644 index 000000000..2470c7af1 --- /dev/null +++ b/backend/crates/gateway-host/tests/outbound/websocket.rs @@ -0,0 +1,155 @@ +use std::time::Duration; + +use futures::{SinkExt as _, StreamExt as _}; +use gateway_core::{account::OutboundProxy, upstream::UpstreamSendState}; +use gateway_host::outbound::{ + HttpClient, HttpErrorKind, HttpRequest, NetworkPolicy, WebSocketMessage, +}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +fn request(url: String) -> HttpRequest { + HttpRequest { + method: "GET".into(), + url, + headers: vec![], + body: vec![], + } +} +fn network() -> NetworkPolicy { + NetworkPolicy::new(&["127.0.0.0/8".into()]).unwrap() +} + +#[tokio::test] +async fn websocket_preserves_frames_handles_ping_and_releases_connection_on_drop() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/responses", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut websocket = tokio_tungstenite::accept_async(socket).await.unwrap(); + assert_eq!( + websocket.next().await.unwrap().unwrap(), + Message::Text("first".into()) + ); + websocket + .send(Message::Ping(vec![1, 2].into())) + .await + .unwrap(); + websocket + .send(Message::Binary(vec![0, 255, 7].into())) + .await + .unwrap(); + assert_eq!( + websocket.next().await.unwrap().unwrap(), + Message::Pong(vec![1, 2].into()) + ); + assert!( + websocket + .next() + .await + .is_none_or(|message| message.is_err()) + ); + }); + let mut response = HttpClient::new() + .unwrap() + .open_websocket(request(url), None, &network(), Duration::from_secs(2)) + .await + .unwrap(); + assert_eq!(response.status, 101); + let mut websocket = response.connection.take().unwrap(); + websocket + .send( + WebSocketMessage::Text("first".into()), + Duration::from_secs(2), + ) + .await + .unwrap(); + let Some(WebSocketMessage::Binary(bytes)) = + websocket.read(Duration::from_secs(2)).await.unwrap() + else { + panic!("binary frame"); + }; + assert_eq!(&bytes[..], &[0, 255, 7]); + drop(websocket); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn websocket_rejections_return_http_body_and_never_fall_back_from_proxy() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(401).set_body_string("denied")) + .expect(1) + .mount(&server) + .await; + let client = HttpClient::new().unwrap(); + let response = client + .open_websocket( + request(server.uri()), + None, + &network(), + Duration::from_secs(2), + ) + .await + .unwrap(); + assert_eq!(response.status, 401); + assert!(response.connection.is_none()); + assert_eq!( + &response.body.unwrap().read(64).await.unwrap().unwrap()[..], + b"denied" + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy = + OutboundProxy::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + drop(listener); + let error = client + .open_websocket( + request(server.uri()), + Some(&proxy), + &network(), + Duration::from_secs(2), + ) + .await + .err() + .unwrap(); + assert_eq!(error.send_state, UpstreamSendState::NotSent); + assert_eq!(error.kind(), HttpErrorKind::Transport); +} + +#[tokio::test] +async fn websocket_refuses_invalid_upgrade_and_header_injection() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + assert!(bytes.len() < 4096); + bytes.push(socket.read_u8().await.unwrap()); + } + socket.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: invalid\r\n\r\n").await.unwrap(); + }); + let client = HttpClient::new().unwrap(); + let mut invalid = request(url.clone()); + invalid + .headers + .push(("sec-websocket-key".into(), b"injected".to_vec())); + let error = client + .open_websocket(invalid, None, &network(), Duration::from_secs(2)) + .await + .err() + .unwrap(); + assert_eq!(error.send_state, UpstreamSendState::NotSent); + let error = client + .open_websocket(request(url), None, &network(), Duration::from_secs(2)) + .await + .err() + .unwrap(); + assert_eq!(error.send_state, UpstreamSendState::Sent); + server.await.unwrap(); +} diff --git a/backend/crates/gateway-plugin/runtime/Cargo.toml b/backend/crates/gateway-plugin/runtime/Cargo.toml index ec485dc13..f19b07124 100644 --- a/backend/crates/gateway-plugin/runtime/Cargo.toml +++ b/backend/crates/gateway-plugin/runtime/Cargo.toml @@ -37,6 +37,7 @@ uuid = { version = "1.23.3", features = ["v4"] } url = "2.5.8" [dev-dependencies] +tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] } wiremock = "0.6.5" gateway-store = { path = "../../gateway-store" } sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres"] } diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index ee23675df..c955b663c 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -9,6 +9,7 @@ { "capability": "model_catalog", "versions": [1] }, { "capability": "retry_policy", "versions": [1] }, { "capability": "middleware", "versions": [1, 2] }, + { "capability": "upstream_adapter", "versions": [1] }, { "capability": "request_lifecycle", "versions": [1] }, { "capability": "web_socket_observer", "versions": [1] }, { "capability": "usage", "versions": [1] }, @@ -18,6 +19,7 @@ ], "permissions": [ "network", + "upstream_connections", "models", "accounts", "data", diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs index 8335b556f..8b36a4f18 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/mod.rs @@ -5,3 +5,4 @@ pub(super) mod management; pub(super) mod observer; pub(super) mod policy; pub(super) mod scope; +pub(super) mod upstream_adapter; diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs new file mode 100644 index 000000000..3759fb472 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs @@ -0,0 +1,364 @@ +use std::time::Duration; + +use bytes::Bytes; +use gateway_core::{ + engine::upstream_adapter::UpstreamAccountConnection, + error::{ClientVisibleUpstreamError, ProviderError, ProviderErrorKind}, + event::{ + ContentItem, ContentKind, EventSequenceValidator, FinishReason, GatewayEvent, + ProtocolWireEvent, ProviderEvent, ProviderResponseObservation, ReasoningDelta, + ResponseMeta, TextDelta, ToolCallDelta, + }, + metering::Usage, + upstream::{OpaqueUpstreamValue, UpstreamSendState, UpstreamTransport}, +}; +use gateway_plugin_sdk::call::{ + model::{CanonicalEvent, WireEvent, WirePayload}, + upstream_adapter::{ + UpstreamAdapterEvent, UpstreamContinuation, UpstreamFailure, UpstreamFailureKind, + }, +}; +use gateway_protocol::openai::sse::SseEventDecoder; + +#[derive(Default)] +pub(super) struct EventDecoder { + sequence: EventSequenceValidator, + response_id: Option, + service_tier: Option, + usage: Usage, + contents: usize, + pub(super) completed: bool, +} + +pub(super) struct DecodedEvent { + pub(super) event: ProviderEvent, + pub(super) continuation: Option, +} + +impl EventDecoder { + pub(super) fn decode( + &mut self, + payload: &[u8], + protocol: &str, + transport: &str, + account: &dyn UpstreamAccountConnection, + sent: UpstreamSendState, + ) -> Result { + let message = UpstreamAdapterEvent::decode(payload).map_err(|_| invalid(sent))?; + if self.completed || message.event.facts.len() > 64 { + return Err(invalid(sent)); + } + let wire = message + .event + .wire + .map(|wire| decode_wire(wire, protocol, sent)) + .transpose()?; + if let Some(failure) = message.failure { + if !message.event.facts.is_empty() || message.continuation.is_some() { + return Err(invalid(sent)); + } + let mut error = failure_error(failure, sent)?; + if let Some(wire) = wire { + error = error.with_atomic_client_events(vec![ProviderEvent::wire(wire)]); + } + return Err(error); + } + if let Some(tier) = message.service_tier { + if tier.is_empty() || tier.len() > 64 || tier.chars().any(char::is_control) { + return Err(invalid(sent)); + } + // 上游可能在完成时才把 auto 解析为实际档位;与原生观测一样保留最新值。 + self.service_tier = Some(tier); + } + let mut facts = Vec::new(); + for fact in message.event.facts { + let fact = match fact { + CanonicalEvent::Started { id, model } => { + validate_id(&id, sent)?; + validate_model(model.as_deref(), sent)?; + self.response_id = Some(id.clone()); + GatewayEvent::Started(meta(id, model)) + } + CanonicalEvent::ContentAdded { index, kind } => { + self.contents += 1; + if self.contents > 4096 { + return Err(invalid(sent)); + } + GatewayEvent::ContentAdded(ContentItem::new( + index, + match kind { + gateway_plugin_sdk::call::model::ContentKind::Text => ContentKind::Text, + gateway_plugin_sdk::call::model::ContentKind::Reasoning => { + ContentKind::Reasoning + } + gateway_plugin_sdk::call::model::ContentKind::ToolCall => { + ContentKind::ToolCall + } + gateway_plugin_sdk::call::model::ContentKind::Image => { + ContentKind::Image + } + gateway_plugin_sdk::call::model::ContentKind::Audio => { + ContentKind::Audio + } + }, + )) + } + CanonicalEvent::TextDelta { index, text } => GatewayEvent::TextDelta(TextDelta { + content_index: index, + text, + }), + CanonicalEvent::ReasoningDelta { index, text } => { + GatewayEvent::ReasoningDelta(ReasoningDelta { + content_index: index, + text, + }) + } + CanonicalEvent::ToolCallDelta { + index, + id, + name, + arguments, + } => { + validate_id(&id, sent)?; + if name + .as_ref() + .is_some_and(|name| name.len() > 512 || name.chars().any(char::is_control)) + { + return Err(invalid(sent)); + } + GatewayEvent::ToolCallDelta(ToolCallDelta { + content_index: index, + call_id: id, + name, + arguments_delta: arguments, + }) + } + CanonicalEvent::Usage { usage } => { + if [ + usage.input_tokens, + usage.output_tokens, + usage.cached_tokens, + usage.cache_write_tokens, + usage.reasoning_tokens, + usage.image_input_tokens, + usage.image_output_tokens, + usage.total_tokens, + ] + .into_iter() + .flatten() + .any(|count| count > i64::MAX as u64) + { + return Err(invalid(sent)); + } + let usage = Usage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cached_tokens: usage.cached_tokens, + cache_write_tokens: usage.cache_write_tokens, + reasoning_tokens: usage.reasoning_tokens, + image_input_tokens: usage.image_input_tokens, + image_output_tokens: usage.image_output_tokens, + total_tokens: usage.total_tokens, + }; + self.usage.merge(&usage); + GatewayEvent::Usage(usage) + } + CanonicalEvent::Completed { id, model, reason } => { + validate_model(model.as_deref(), sent)?; + if self.response_id.as_deref() != Some(id.as_str()) { + return Err(invalid(sent)); + } + if let Some(cost) = + account.calculate_cost(self.service_tier.as_deref(), &self.usage) + { + let cost = GatewayEvent::CalculatedCost(cost); + self.sequence.observe(&cost).map_err(|_| invalid(sent))?; + facts.push(cost); + } + self.completed = true; + GatewayEvent::Completed(meta(id, model).with_finish_reason(match reason { + gateway_plugin_sdk::call::model::FinishReason::Stop => FinishReason::Stop, + gateway_plugin_sdk::call::model::FinishReason::Length => { + FinishReason::Length + } + gateway_plugin_sdk::call::model::FinishReason::ToolCall => { + FinishReason::ToolCall + } + gateway_plugin_sdk::call::model::FinishReason::ContentFilter => { + FinishReason::ContentFilter + } + gateway_plugin_sdk::call::model::FinishReason::Other => FinishReason::Other, + })) + } + }; + self.sequence.observe(&fact).map_err(|_| invalid(sent))?; + facts.push(fact); + } + if message.continuation.is_some() && !self.completed { + return Err(invalid(sent)); + } + let mut event = if let Some(wire) = wire { + ProviderEvent::canonical_with_wire(facts, wire) + } else { + let mut facts = facts.into_iter(); + let first = facts.next().ok_or_else(|| invalid(sent))?; + facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact) + }; + if let Some(tier) = &self.service_tier { + let observation = ProviderResponseObservation::new( + UpstreamTransport::new(transport).map_err(|_| invalid(sent))?, + ) + .try_with_service_tier(tier.clone()) + .map_err(|_| invalid(sent))?; + event.attach_observation(observation); + } + Ok(DecodedEvent { + event, + continuation: message.continuation, + }) + } + + pub(super) fn finish(&self, sent: UpstreamSendState) -> Result<(), ProviderError> { + self.sequence.finish().map_err(|_| invalid(sent)) + } +} + +fn meta(id: String, model: Option) -> ResponseMeta { + model.map_or_else( + || ResponseMeta::for_provider_endpoint(id.clone()), + |model| ResponseMeta::new(id.clone(), model), + ) +} + +fn validate_id(id: &str, sent: UpstreamSendState) -> Result<(), ProviderError> { + if id.is_empty() || id.len() > 512 || id.chars().any(char::is_control) { + return Err(invalid(sent)); + } + Ok(()) +} + +fn validate_model(model: Option<&str>, sent: UpstreamSendState) -> Result<(), ProviderError> { + if model.is_some_and(|model| { + model.is_empty() || model.len() > 256 || model.chars().any(char::is_control) + }) { + return Err(invalid(sent)); + } + Ok(()) +} + +fn decode_wire( + wire: WireEvent, + protocol: &str, + sent: UpstreamSendState, +) -> Result { + if wire.protocol != protocol { + return Err(invalid(sent)); + } + let result = match wire.payload { + WirePayload::Json { + event, + data, + id, + retry, + raw_sse, + } => { + if let Some(raw) = raw_sse { + let frame = single_sse(&raw, sent)?; + let [parsed] = frame.events() else { + return Err(invalid(sent)); + }; + if parsed.event != event + || parsed.id != id + || parsed.retry != retry + || serde_json::from_str::(&parsed.data) + .ok() + .as_ref() + != Some(&data) + { + return Err(invalid(sent)); + } + ProtocolWireEvent::json_with_raw_sse_metadata( + protocol, + event, + data, + Bytes::from(raw), + id, + retry, + ) + } else { + ProtocolWireEvent::json_with_sse_metadata(protocol, event, data, id, retry) + } + } + WirePayload::RawSse { frame } => { + single_sse(&frame, sent)?; + ProtocolWireEvent::raw_sse(protocol, Bytes::from(frame)) + } + WirePayload::RawJson { body } => { + serde_json::from_slice::(&body).map_err(|_| invalid(sent))?; + ProtocolWireEvent::raw_json(protocol, Bytes::from(body)) + } + WirePayload::RawBody { body } => { + ProtocolWireEvent::raw_http_body(protocol, Bytes::from(body)) + } + }; + result.map_err(|_| invalid(sent)) +} + +fn single_sse( + bytes: &[u8], + sent: UpstreamSendState, +) -> Result { + let mut frames = SseEventDecoder::default().push_frames(bytes); + if frames.len() != 1 || frames[0].raw() != bytes || std::str::from_utf8(bytes).is_err() { + return Err(invalid(sent)); + } + frames.pop().ok_or_else(|| invalid(sent)) +} + +fn failure_error( + failure: UpstreamFailure, + sent: UpstreamSendState, +) -> Result { + if failure.message.len() > 64 * 1024 + || failure.code.as_ref().is_some_and(|code| code.len() > 256) + || failure + .status + .is_some_and(|status| !(400..=599).contains(&status)) + || failure + .retry_after_ms + .is_some_and(|delay| delay > 24 * 60 * 60 * 1000) + { + return Err(invalid(sent)); + } + let kind = match failure.kind { + UpstreamFailureKind::InvalidRequest => ProviderErrorKind::InvalidRequest, + UpstreamFailureKind::Unsupported => ProviderErrorKind::Unsupported, + UpstreamFailureKind::Unauthorized => ProviderErrorKind::Unauthorized, + UpstreamFailureKind::PermissionDenied => ProviderErrorKind::PermissionDenied, + UpstreamFailureKind::RateLimited => ProviderErrorKind::RateLimited, + UpstreamFailureKind::QuotaExhausted => ProviderErrorKind::QuotaExhausted, + UpstreamFailureKind::Timeout => ProviderErrorKind::Timeout, + UpstreamFailureKind::Unavailable => ProviderErrorKind::Unavailable, + UpstreamFailureKind::Protocol => ProviderErrorKind::Protocol, + }; + let mut error = ProviderError::new(kind, sent); + if let Some(code) = &failure.code { + error = error.with_upstream_code(OpaqueUpstreamValue::new(code.clone())); + } + error = error.with_client_visible_upstream_error(ClientVisibleUpstreamError::new( + failure.message, + failure.code, + None, + )); + if let Some(status) = failure.status { + error = error.with_status(status); + } + if let Some(delay) = failure.retry_after_ms { + error = error.with_retry_after(Duration::from_millis(delay)); + } + Ok(error) +} + +pub(super) fn invalid(sent: UpstreamSendState) -> ProviderError { + ProviderError::new(ProviderErrorKind::Protocol, sent) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs new file mode 100644 index 000000000..3f5c982ed --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs @@ -0,0 +1,386 @@ +use std::{sync::Arc, time::SystemTime}; + +use futures::stream; +use gateway_core::{ + engine::{provider::EventStream, upstream_adapter::UpstreamAdapterInvocation}, + error::{ProviderError, ProviderErrorKind}, + operation::{ExtensionSessionOwner, ProviderSessionState}, + upstream::UpstreamSendState, +}; +use gateway_plugin_sdk::{ + CallContext, ErrorCode, PluginFault, Stage, + call::upstream_adapter::{ + ContinuationScope, EXECUTE_METHOD, UpstreamAdapterRequest, UpstreamContinuation, + UpstreamTransport, + }, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + PluginUpstreamAdapter, + event::{DecodedEvent, EventDecoder, invalid}, +}; +use crate::{ + RpcError, RpcStream, + callback::{ + NetworkScope, + upstream::{ConnectionOwner, ManagedUpstream}, + }, +}; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredContinuation { + continuation: UpstreamContinuation, + client_key_id: String, + account_id: String, + credential_revision: u64, + connection: Option, +} + +struct Execution { + adapter: Arc, + invocation: UpstreamAdapterInvocation, + active: Option, + decoder: EventDecoder, + ended: bool, +} + +struct ActiveCall { + context: CallContext, + stream: RpcStream, + managed: Arc, + _scope: Arc, +} + +pub(super) fn execute( + adapter: Arc, + invocation: UpstreamAdapterInvocation, +) -> EventStream { + let state = Execution { + adapter, + invocation, + active: None, + decoder: EventDecoder::default(), + ended: false, + }; + // try_unfold 首次 poll 才运行闭包;Core 在此前登记 attempt 并持有账号租约。 + Box::pin(stream::try_unfold(state, |mut state| async move { + if state.ended { + return Ok(None); + } + let result = state.next().await; + match result { + Ok(event) => Ok(Some((event, state))), + Err(error) => Err(state.invocation.account.record_failure(error).await), + } + })) +} + +impl Execution { + async fn next(&mut self) -> Result { + if self.active.is_none() { + self.active = Some(self.begin().await?); + } + let active = self + .active + .as_mut() + .ok_or_else(|| invalid(UpstreamSendState::NotSent))?; + let cancellation = self.invocation.context.cancellation(); + let chunk = tokio::select! { + biased; + () = cancellation.cancelled() => return Err(ProviderError::new(ProviderErrorKind::Cancelled, active.managed.send_state.get())), + result = active.stream.next() => result.map_err(|error| rpc_error(error, active.managed.send_state.get()))?, + }.ok_or_else(|| invalid(active.managed.send_state.get()))?; + let mut decoded = self.decoder.decode( + &chunk, + &self.adapter.declaration.protocol, + self.adapter.declaration.transport.as_str(), + self.invocation.account.as_ref(), + active.managed.send_state.get(), + )?; + if self.decoder.completed { + // 先确认 RPC 正常终结,再发布唯一 Completed;否则 Core 不会继续 poll 尾部错误。 + let terminal = async { + let tail = tokio::select! { + biased; + () = cancellation.cancelled() => return Err(ProviderError::new(ProviderErrorKind::Cancelled, active.managed.send_state.get())), + result = active.stream.next() => result.map_err(|error| rpc_error(error, active.managed.send_state.get()))?, + }; + if tail.is_some() { + return Err(invalid(active.managed.send_state.get())); + } + self.decoder.finish(active.managed.send_state.get())?; + attach_continuation(&self.adapter, &self.invocation, active, &mut decoded).await + }.await; + if let Err(error) = terminal { + // 尾部故障不得交付成功 wire,但已经解析的计量事实仍归 Core 结算。 + let metering = decoded + .event + .canonical_facts() + .iter() + .filter(|fact| { + matches!( + fact, + gateway_core::event::GatewayEvent::Usage(_) + | gateway_core::event::GatewayEvent::CalculatedCost(_) + ) + }) + .cloned() + .map(gateway_core::event::ProviderEvent::canonical) + .collect(); + return Err(error.with_atomic_client_events(metering)); + } + self.ended = true; + } + Ok(decoded.event) + } + + async fn begin(&self) -> Result { + let invalid = || invalid(UpstreamSendState::NotSent); + let declaration = &self.adapter.declaration; + let invocation = &self.invocation; + if invocation.operation.protocol() != declaration.protocol + || invocation.metadata.provider().as_str() != declaration.provider.as_str() + || invocation.metadata.provider_account_id() != invocation.account.account_id() + || !declaration + .authentication_kinds + .iter() + .any(|kind| kind == invocation.account.authentication_kind()) + { + return Err(invalid()); + } + let timeout = invocation + .context + .deadline() + .duration_since(SystemTime::now()) + .map_err(|_| { + ProviderError::new(ProviderErrorKind::Timeout, UpstreamSendState::NotSent) + })? + .min(self.adapter.session.maximum_call_timeout()); + if timeout.is_zero() || invocation.context.cancellation().is_cancelled() { + return Err(ProviderError::new( + ProviderErrorKind::Cancelled, + UpstreamSendState::NotSent, + )); + } + let mut context = self.adapter.session.context(Stage::Upstream, timeout); + context.request_id = Some(invocation.context.request_id().as_str().to_owned()); + context.attempt_id = Some(format!( + "{}:{}", + invocation.context.request_id().as_str(), + invocation.context.attempt_index() + )); + context.account_id = Some(invocation.account.account_id().as_str().to_owned()); + context.credential_revision = Some(invocation.account.credential_revision().get()); + let owner = ConnectionOwner { + client_key_id: invocation.context.client_api_key_ref().as_str().to_owned(), + account_id: invocation.account.account_id().as_str().to_owned(), + credential_revision: invocation.account.credential_revision().get(), + }; + let previous = previous_continuation(&self.adapter, invocation, &context, &owner)?; + let managed = ManagedUpstream::new( + Arc::clone(&invocation.account), + Arc::clone(&self.adapter.target), + invocation.context.execution_effects(), + Arc::clone(&self.adapter.connections), + owner, + previous + .as_ref() + .and_then(|state| state.connection.as_deref()), + declaration.transport == UpstreamTransport::WebSocket, + ) + .map_err(|error| fault_error(error, UpstreamSendState::NotSent))?; + let extension_scope = invocation + .context + .extension_scope() + .extending(self.adapter.instance_id.clone()) + .ok_or_else(invalid)?; + let scope = self + .adapter + .callbacks + .prepare_upstream(&context, Arc::clone(&managed), extension_scope) + .map_err(|error| fault_error(error, UpstreamSendState::NotSent))?; + let request = UpstreamAdapterRequest { + adapter_id: declaration.id.clone(), + provider: declaration.provider, + upstream_model: invocation + .metadata + .upstream_model() + .ok_or_else(invalid)? + .as_str() + .to_owned(), + client_key_id: invocation.context.client_api_key_ref().as_str().to_owned(), + account_id: invocation.account.account_id().as_str().to_owned(), + credential_revision: invocation.account.credential_revision().get(), + protocol: invocation.operation.protocol().to_owned(), + client_transport: invocation.context.client_transport().as_str().to_owned(), + disable_fast: invocation.context.disable_fast(), + headers: invocation + .headers + .iter() + .map(|header| (header.name().to_owned(), header.value().to_vec())) + .collect(), + continuation: previous.map(|state| state.continuation), + }; + let payload = request + .encode( + invocation + .operation + .middleware_body() + .map_err(|_| invalid())? + .to_vec(), + ) + .map_err(|_| invalid())?; + invocation.context.trace().record("plugin.upstream", serde_json::json!({ "instanceId": self.adapter.instance_id, "adapterId": declaration.id, "generation": context.generation, "transport": declaration.transport.as_str() })); + let stream = tokio::select! { + biased; + () = invocation.context.cancellation().cancelled() => return Err(ProviderError::new(ProviderErrorKind::Cancelled, managed.send_state.get())), + result = self.adapter.session.call_stream(EXECUTE_METHOD, context.clone(), serde_json::json!({}), payload) => result.map_err(|error| rpc_error(error, managed.send_state.get()))?, + }; + if stream.initial.result != serde_json::json!({}) || !stream.initial.payload.is_empty() { + return Err(super::event::invalid(managed.send_state.get())); + } + Ok(ActiveCall { + context, + stream, + managed, + _scope: scope, + }) + } +} + +fn previous_continuation( + adapter: &PluginUpstreamAdapter, + invocation: &UpstreamAdapterInvocation, + context: &CallContext, + owner: &ConnectionOwner, +) -> Result, ProviderError> { + let invalid = || invalid(UpstreamSendState::NotSent); + let Some(state) = invocation + .operation + .provider_session_state(adapter.declaration.provider.as_str()) + else { + return if invocation.context.continuation().is_some() { + Err(invalid()) + } else { + Ok(None) + }; + }; + let expected = extension_owner( + adapter, + context, + state + .extension_owner() + .is_some_and(|owner| owner.connection_local), + ); + if state.extension_owner() != Some(&expected) { + return Err(invalid()); + } + let stored: StoredContinuation = + serde_json::from_value(serde_json::Value::Object(state.payload().clone())) + .map_err(|_| invalid())?; + if stored.client_key_id != owner.client_key_id + || stored.account_id != owner.account_id + || stored.credential_revision != owner.credential_revision + || (stored.continuation.scope == ContinuationScope::ConnectionLocal) + != stored.connection.is_some() + || expected.connection_local != stored.connection.is_some() + { + return Err(invalid()); + } + Ok(Some(stored)) +} + +async fn attach_continuation( + adapter: &PluginUpstreamAdapter, + invocation: &UpstreamAdapterInvocation, + active: &ActiveCall, + decoded: &mut DecodedEvent, +) -> Result<(), ProviderError> { + let invalid = || invalid(active.managed.send_state.get()); + let Some(continuation) = decoded.continuation.take() else { + if invocation + .operation + .provider_session_state(adapter.declaration.provider.as_str()) + .is_some() + { + return Err(invalid()); + } + return Ok(()); + }; + if continuation.upstream_response_id.is_empty() + || continuation.upstream_response_id.len() > 512 + || serde_json::to_vec(&continuation) + .map_err(|_| invalid())? + .len() + > 32 * 1024 + { + return Err(invalid()); + } + let local = continuation.scope == ContinuationScope::ConnectionLocal; + let connection = if local { + Some( + active + .managed + .retain_connection() + .await + .map_err(|error| fault_error(error, active.managed.send_state.get()))?, + ) + } else { + None + }; + let state = StoredContinuation { + continuation, + connection, + client_key_id: invocation.context.client_api_key_ref().as_str().to_owned(), + account_id: invocation.account.account_id().as_str().to_owned(), + credential_revision: invocation.account.credential_revision().get(), + }; + let serde_json::Value::Object(payload) = serde_json::to_value(state).map_err(|_| invalid())? + else { + return Err(invalid()); + }; + let state = ProviderSessionState::new(adapter.declaration.provider.as_str(), payload) + .map_err(|_| invalid())? + .with_extension_owner(extension_owner(adapter, &active.context, local)); + decoded.event.attach_session_update(state); + Ok(()) +} + +fn extension_owner( + adapter: &PluginUpstreamAdapter, + context: &CallContext, + connection_local: bool, +) -> ExtensionSessionOwner { + ExtensionSessionOwner { + instance_id: adapter.instance_id.clone(), + contribution_id: adapter.contribution_id.clone(), + adapter_id: adapter.declaration.id.clone(), + generation: context.generation, + incarnation: context.incarnation.clone(), + connection_local, + } +} + +fn rpc_error(error: RpcError, sent: UpstreamSendState) -> ProviderError { + match error { + RpcError::Remote(fault) => fault_error(fault, sent), + RpcError::Timeout => ProviderError::new(ProviderErrorKind::Timeout, sent), + RpcError::Cancelled => ProviderError::new(ProviderErrorKind::Cancelled, sent), + _ => ProviderError::new(ProviderErrorKind::Unavailable, sent), + } +} + +fn fault_error(fault: PluginFault, sent: UpstreamSendState) -> ProviderError { + let kind = match fault.code { + ErrorCode::Timeout => ProviderErrorKind::Timeout, + ErrorCode::Cancelled => ProviderErrorKind::Cancelled, + ErrorCode::Unsupported => ProviderErrorKind::Unsupported, + ErrorCode::InvalidInput | ErrorCode::Rejected | ErrorCode::PermissionDenied => { + ProviderErrorKind::InvalidRequest + } + _ => ProviderErrorKind::Unavailable, + }; + ProviderError::new(kind, sent) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs new file mode 100644 index 000000000..dbeb90fc7 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs @@ -0,0 +1,157 @@ +mod event; +mod execution; +mod registration; +pub(crate) mod target; + +use std::{fmt, sync::Arc}; + +use gateway_admin::model::AdminError; +use gateway_core::{ + engine::{ + AttemptContext, + upstream_adapter::{UpstreamAdapter, UpstreamAdapterInvocation, UpstreamAdapterPlan}, + }, + error::{ProviderError, ProviderErrorKind}, + identity::ProviderKind, + routing::UpstreamModelId, + upstream::UpstreamSendState, +}; +use gateway_plugin_sdk::call::upstream_adapter::UpstreamAdapterDeclaration; + +use super::scope::BindingScope; +use crate::{RpcSession, callback::PluginCallbacks}; + +pub(crate) use registration::{prepare, unavailable_entries, validate_bindings}; + +pub(crate) struct AdapterEntry { + instance_id: String, + scope: BindingScope, + adapter: Option>, +} + +pub(crate) struct PluginUpstreamAdapter { + instance_id: String, + contribution_id: String, + declaration: UpstreamAdapterDeclaration, + target: Arc, + session: Arc, + callbacks: Arc, + connections: Arc, +} + +impl fmt::Debug for PluginUpstreamAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PluginUpstreamAdapter") + .field("instance_id", &self.instance_id) + .field("adapter_id", &self.declaration.id) + .finish_non_exhaustive() + } +} + +impl UpstreamAdapter for PluginUpstreamAdapter { + fn transport(&self) -> &str { + self.declaration.transport.as_str() + } + + fn execute( + self: Arc, + invocation: UpstreamAdapterInvocation, + ) -> gateway_core::engine::provider::EventStream { + execution::execute(self, invocation) + } +} + +pub(crate) struct PluginUpstreamAdapterPlan { + entries: Vec, +} + +impl PluginUpstreamAdapterPlan { + pub(crate) fn compile( + entries: Vec, + ) -> Result>, AdminError> { + for (index, left) in entries.iter().enumerate() { + for right in &entries[index + 1..] { + if left.scope.overlaps(&right.scope) + && adapters_overlap(left.adapter.as_deref(), right.adapter.as_deref()) + { + return Err(AdminError::invalid( + "上游适配器的 Provider 与模型绑定范围重叠", + )); + } + } + } + Ok((!entries.is_empty()) + .then(|| Arc::new(Self { entries }) as Arc)) + } +} + +impl fmt::Debug for PluginUpstreamAdapterPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PluginUpstreamAdapterPlan") + .field("adapters", &self.entries.len()) + .finish() + } +} + +impl UpstreamAdapterPlan for PluginUpstreamAdapterPlan { + fn select( + &self, + context: &AttemptContext, + provider: &ProviderKind, + model: &UpstreamModelId, + ) -> Result>, ProviderError> { + let model = context + .requested_model() + .map_or(model.as_str(), |model| model.as_str()); + for entry in &self.entries { + if context.extension_scope().contains(&entry.instance_id) + || !entry.scope.matches_processing( + context.client_api_key_ref(), + context.account_group_ids(), + Some(provider), + Some(model), + ) + { + continue; + } + let adapter = entry.adapter.as_ref().ok_or_else(unavailable)?; + if adapter.declaration.provider.as_str() == provider.as_str() + && (adapter.declaration.models.is_empty() + || adapter + .declaration + .models + .iter() + .any(|candidate| candidate == model)) + { + if !adapter.session.is_ready() { + return Err(unavailable()); + } + return Ok(Some(Arc::clone(adapter) as Arc)); + } + } + Ok(None) + } +} + +fn adapters_overlap( + left: Option<&PluginUpstreamAdapter>, + right: Option<&PluginUpstreamAdapter>, +) -> bool { + let (Some(left), Some(right)) = (left, right) else { + return true; + }; + left.declaration.provider == right.declaration.provider + && (left.declaration.models.is_empty() + || right.declaration.models.is_empty() + || left + .declaration + .models + .iter() + .any(|model| right.declaration.models.contains(model))) +} + +fn unavailable() -> ProviderError { + ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs new file mode 100644 index 000000000..83cbf2744 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs @@ -0,0 +1,154 @@ +use std::{collections::BTreeSet, sync::Arc, time::Duration}; + +use gateway_admin::model::{ + AdminError, + plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy, PluginInstance}, +}; +use gateway_plugin_sdk::{ + Capability, Manifest, Permission, Stage, + call::upstream_adapter::{REGISTER_METHOD, UpstreamAdapterRegistration}, +}; + +use super::{AdapterEntry, PluginUpstreamAdapter, target::UpstreamTarget}; +use crate::{RpcSession, adapter::scope::BindingScope, callback::PluginCallbacks}; + +pub(crate) fn validate_bindings( + manifest: &Manifest, + bindings: &[PluginCapabilityBinding], +) -> Result<(), AdminError> { + let Some(declaration) = manifest.contributes.get(&Capability::UpstreamAdapter) else { + return Ok(()); + }; + let mut found = false; + for binding in bindings + .iter() + .filter(|binding| binding.contribution == declaration.id) + { + if std::mem::replace(&mut found, true) + || binding.stage != "upstream" + || binding.failure_policy != PluginFailurePolicy::Reject + || binding + .provider_ids + .iter() + .any(|provider| !matches!(provider.as_str(), "openai" | "xai")) + { + return Err(AdminError::invalid( + "上游适配器只允许一次 upstream 绑定,使用 reject 策略及内置 Provider 范围", + )); + } + BindingScope::compile(binding)?; + } + Ok(()) +} + +pub(crate) async fn prepare( + manifest: &Manifest, + instance: &PluginInstance, + session: Arc, + callbacks: Arc, +) -> Result, AdminError> { + let Some(contribution) = manifest.contributes.get(&Capability::UpstreamAdapter) else { + return Ok(Vec::new()); + }; + validate_bindings(manifest, &instance.bindings)?; + for permission in [Permission::Requests, Permission::UpstreamConnections] { + if !instance + .grants + .iter() + .any(|grant| grant.permission == permission.as_str()) + { + return Err(AdminError::invalid( + "上游适配器需要 requests 和 upstream_connections 授权", + )); + } + } + let reply = session + .call( + REGISTER_METHOD, + session.context(Stage::Registration, Duration::from_secs(5)), + serde_json::json!({}), + vec![], + ) + .await + .map_err(|_| AdminError::invalid("上游适配器注册失败"))?; + if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 { + return Err(AdminError::invalid("上游适配器注册信封无效或过大")); + } + let registration: UpstreamAdapterRegistration = serde_json::from_slice(&reply.payload) + .map_err(|_| AdminError::invalid("上游适配器声明无效"))?; + if registration.adapters.is_empty() || registration.adapters.len() > 16 { + return Err(AdminError::invalid("单个插件须声明 1 至 16 个上游适配器")); + } + let mut ids = BTreeSet::new(); + let binding = instance + .bindings + .iter() + .find(|binding| binding.contribution == contribution.id); + let mut entries = Vec::new(); + for declaration in registration.adapters { + if declaration.id.is_empty() + || declaration.id.len() > 64 + || !declaration + .id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + || !ids.insert(declaration.id.clone()) + || declaration.authentication_kinds.is_empty() + || declaration.authentication_kinds.len() > 8 + || declaration.authentication_kinds.iter().any(|kind| { + kind.is_empty() + || kind.len() > 64 + || !kind + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + || !contribution.input_formats.contains(&declaration.protocol) + || !contribution.output_formats.contains(&declaration.protocol) + || declaration.models.len() > 256 + { + return Err(AdminError::invalid("上游适配器身份、认证或协议范围无效")); + } + let mut models = BTreeSet::new(); + for model in &declaration.models { + gateway_core::routing::PublicModelId::new(model.clone()) + .map_err(|_| AdminError::invalid("上游适配器模型无效"))?; + if !models.insert(model) { + return Err(AdminError::invalid("上游适配器模型重复")); + } + } + let target = Arc::new(UpstreamTarget::compile(&declaration)?); + if let Some(binding) = binding { + entries.push(AdapterEntry { + instance_id: instance.id.clone(), + scope: BindingScope::compile(binding)?, + adapter: Some(Arc::new(PluginUpstreamAdapter { + instance_id: instance.id.clone(), + contribution_id: contribution.id.clone(), + declaration, + target, + session: Arc::clone(&session), + callbacks: Arc::clone(&callbacks), + connections: Arc::default(), + })), + }); + } + } + Ok(entries) +} + +pub(crate) fn unavailable_entries( + instance: &PluginInstance, +) -> Result, AdminError> { + instance + .bindings + .iter() + .filter(|binding| binding.stage == "upstream") + .map(|binding| { + Ok(AdapterEntry { + instance_id: instance.id.clone(), + scope: BindingScope::compile(binding)?, + adapter: None, + }) + }) + .collect() +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs new file mode 100644 index 000000000..b0c3d0c31 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeMap; + +use gateway_admin::model::AdminError; +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::upstream_adapter::{UpstreamAdapterDeclaration, UpstreamPathPurpose}, +}; +use url::Url; + +pub(crate) struct UpstreamTarget { + base: Url, + paths: BTreeMap, +} + +impl UpstreamTarget { + pub(super) fn compile(declaration: &UpstreamAdapterDeclaration) -> Result { + let base = Url::parse(&declaration.base_url) + .map_err(|_| AdminError::invalid("上游 Base URL 无效"))?; + if !matches!(base.scheme(), "https" | "http") + || base.host_str().is_none() + || !base.username().is_empty() + || base.password().is_some() + || base.query().is_some() + || base.fragment().is_some() + || !base.path().ends_with('/') + || declaration.base_url.len() > 2048 + || declaration.paths.is_empty() + || declaration.paths.len() > 16 + { + return Err(AdminError::invalid( + "上游 Base URL 须为无凭据、查询串或片段且以 / 结尾的 HTTP(S) 地址", + )); + } + let mut paths = BTreeMap::new(); + for path in &declaration.paths { + if !valid_path(&path.path) || paths.insert(path.path.clone(), path.purpose).is_some() { + return Err(AdminError::invalid("上游业务路径无效或重复")); + } + } + if !paths + .values() + .any(|purpose| *purpose == UpstreamPathPurpose::Inference) + { + return Err(AdminError::invalid("上游适配器须声明推理路径")); + } + Ok(Self { base, paths }) + } + + pub(crate) fn resolve( + &self, + path: &str, + query: &[(String, String)], + ) -> Result<(Url, UpstreamPathPurpose), PluginFault> { + let purpose = *self.paths.get(path).ok_or_else(invalid)?; + if query.len() > 64 + || query + .iter() + .any(|(key, value)| key.len() > 256 || value.len() > 4096) + { + return Err(invalid()); + } + let mut target = self.base.join(path).map_err(|_| invalid())?; + if target.origin() != self.base.origin() || !target.path().starts_with(self.base.path()) { + return Err(invalid()); + } + if !query.is_empty() { + target + .query_pairs_mut() + .extend_pairs(query.iter().map(|(key, value)| (key, value))); + } + Ok((target, purpose)) + } +} + +fn valid_path(path: &str) -> bool { + !path.is_empty() + && path.len() <= 2048 + && path.is_ascii() + && !path.starts_with('/') + && path.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~') + }) + && path + .split('/') + .all(|segment| !matches!(segment, "" | "." | "..")) +} + +pub(crate) fn protected_header(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + matches!( + name.as_str(), + "authorization" + | "proxy-authorization" + | "cookie" + | "host" + | "connection" + | "upgrade" + | "transfer-encoding" + | "content-length" + | "proxy-connection" + | "te" + | "trailer" + ) || name.starts_with("sec-websocket-") +} + +fn invalid() -> PluginFault { + PluginFault::new(ErrorCode::InvalidInput, "upstream target is not authorized") +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http.rs b/backend/crates/gateway-plugin/runtime/src/callback/http.rs index 15cd5111c..869d32e4a 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/http.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/http.rs @@ -8,30 +8,112 @@ use serde::de::DeserializeOwned; use super::{CallResources, HttpAuthorization, HttpStream, NetworkScope, denied, invalid}; use crate::RpcReply; -#[expect( - clippy::too_many_arguments, - reason = "回调只接受宿主已解析的调用资源与权限,不重建第二份上下文" -)] -pub(super) async fn dispatch( - client: &HttpClient, - authorization: &HttpAuthorization, - scope: &Arc, - call: &CallResources, - method: &str, - params: serde_json::Value, - payload: Vec, - maximum_payload: usize, -) -> Result { - match method { - "host.http.do" | "host.http.do_stream" => { - let request: wire::HttpRequest = decode(params)?; - let timeout = call - .deadline - .checked_duration_since(tokio::time::Instant::now()) - .ok_or_else(|| PluginFault::new(ErrorCode::Timeout, "callback deadline elapsed"))?; - let attempt = scope.start_http(); - let response = client - .open( +/// 回调的公共网络外层;账号鉴权只装饰请求,发送、读流和回收共用同一终端。 +pub(super) struct HttpCallbacks<'a> { + pub(super) client: &'a HttpClient, + pub(super) authorization: &'a HttpAuthorization, + pub(super) scope: &'a Arc, + pub(super) call: &'a CallResources, + pub(super) maximum_payload: usize, +} + +impl HttpCallbacks<'_> { + pub(super) async fn send( + &self, + request: HttpRequest, + streaming: bool, + upstream_purpose: Option, + ) -> Result { + let Self { + client, + authorization, + scope, + call, + maximum_payload, + } = *self; + let timeout = call + .deadline + .checked_duration_since(tokio::time::Instant::now()) + .ok_or_else(|| PluginFault::new(ErrorCode::Timeout, "callback deadline elapsed"))?; + let attempt = scope.start_upstream(upstream_purpose); + let response = client + .open( + request, + scope.proxy.as_ref(), + &authorization.network, + timeout, + ) + .await; + attempt.finish( + response + .as_ref() + .map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent), + ); + let response = response.map_err(http_error)?; + let mut body = response.body; + let headers = response + .headers + .into_iter() + .map(|(name, value)| String::from_utf8(value).map(|value| (name, value))) + .collect::, _>>() + .map_err(|_| sent_fault(ErrorCode::Fault, "HTTP header cannot be represented"))?; + let mut result = wire::HttpResponse { + status: response.status, + headers, + stream: None, + }; + let mut payload = Vec::new(); + if streaming { + let id = uuid::Uuid::new_v4().to_string(); + let mut state = call + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.closed { + return Err(denied()); + } + if state.streams.len() >= 16 { + return Err(sent_fault( + ErrorCode::Capacity, + "HTTP stream capacity exhausted", + )); + } + state.streams.insert( + id.clone(), + Arc::new(HttpStream { + body: tokio::sync::Mutex::new(Some(body)), + closed: tokio::sync::watch::channel(false).0, + }), + ); + result.stream = Some(id); + } else { + while let Some(chunk) = body.read(64 * 1024).await.map_err(http_error)? { + if payload.len() + chunk.len() > maximum_payload { + return Err(sent_fault( + ErrorCode::Capacity, + "HTTP body requires streaming", + )); + } + payload.extend_from_slice(&chunk); + } + } + Ok(RpcReply { + result: serde_json::to_value(result).map_err(|_| invalid())?, + payload, + }) + } + + pub(super) async fn dispatch( + &self, + method: &str, + params: serde_json::Value, + payload: Vec, + ) -> Result { + let call = self.call; + match method { + "host.http.do" | "host.http.do_stream" => { + let request: wire::HttpRequest = decode(params)?; + self.send( HttpRequest { method: request.method, url: request.url, @@ -42,128 +124,71 @@ pub(super) async fn dispatch( .collect(), body: payload, }, - scope.proxy.as_ref(), - &authorization.network, - timeout, + method == "host.http.do_stream", + None, ) - .await; - attempt.finish( - response - .as_ref() - .map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent), - ); - let response = response.map_err(http_error)?; - let mut body = response.body; - let headers = response - .headers - .into_iter() - .map(|(name, value)| String::from_utf8(value).map(|value| (name, value))) - .collect::, _>>() - .map_err(|_| sent_fault(ErrorCode::Fault, "HTTP header cannot be represented"))?; - let mut result = wire::HttpResponse { - status: response.status, - headers, - stream: None, - }; - let mut payload = Vec::new(); - if method == "host.http.do_stream" { - let id = uuid::Uuid::new_v4().to_string(); - let mut state = call + .await + } + "host.http.stream_read" => { + if !payload.is_empty() { + return Err(invalid()); + } + let request: wire::StreamRead = decode(params)?; + if request.maximum_bytes == 0 || request.maximum_bytes > 64 * 1024 { + return Err(invalid()); + } + let stream = call .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.closed { - return Err(denied()); - } - if state.streams.len() >= 16 { - return Err(sent_fault( - ErrorCode::Capacity, - "HTTP stream capacity exhausted", - )); - } - state.streams.insert( - id.clone(), - Arc::new(HttpStream { - body: tokio::sync::Mutex::new(Some(body)), - closed: tokio::sync::watch::channel(false).0, - }), - ); - result.stream = Some(id); - } else { - while let Some(chunk) = body.read(64 * 1024).await.map_err(http_error)? { - if payload.len() + chunk.len() > maximum_payload { - return Err(sent_fault( - ErrorCode::Capacity, - "HTTP body requires streaming", - )); - } - payload.extend_from_slice(&chunk); - } - } - Ok(RpcReply { - result: serde_json::to_value(result).map_err(|_| invalid())?, - payload, - }) - } - "host.http.stream_read" => { - if !payload.is_empty() { - return Err(invalid()); - } - let request: wire::StreamRead = decode(params)?; - if request.maximum_bytes == 0 || request.maximum_bytes > 64 * 1024 { - return Err(invalid()); - } - let stream = call - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .streams - .get(&request.stream) - .cloned() - .ok_or_else(denied)?; - let mut body = stream.body.try_lock().map_err(|_| { - PluginFault::new(ErrorCode::Capacity, "HTTP stream already has a reader") - })?; - let mut closed = stream.closed.subscribe(); - let result = tokio::select! { - biased; - _ = closed.wait_for(|closed| *closed) => Err(denied()), - result = body.as_mut().ok_or_else(denied)?.read(request.maximum_bytes as usize) => result.map_err(http_error), - }; - if !matches!(&result, Ok(Some(_))) { - body.take(); - stream.closed.send_replace(true); - call.state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .streams - .remove(&request.stream); + .get(&request.stream) + .cloned() + .ok_or_else(denied)?; + let mut body = stream.body.try_lock().map_err(|_| { + PluginFault::new(ErrorCode::Capacity, "HTTP stream already has a reader") + })?; + let mut closed = stream.closed.subscribe(); + let result = tokio::select! { + biased; + _ = closed.wait_for(|closed| *closed) => Err(denied()), + result = body.as_mut().ok_or_else(denied)?.read(request.maximum_bytes as usize) => result.map_err(http_error), + }; + if !matches!(&result, Ok(Some(_))) { + body.take(); + stream.closed.send_replace(true); + call.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .streams + .remove(&request.stream); + } + let chunk = result?; + Ok(RpcReply { + result: serde_json::json!({"eof":chunk.is_none()}), + payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()), + }) } - let chunk = result?; - Ok(RpcReply { - result: serde_json::json!({"eof":chunk.is_none()}), - payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()), - }) - } - "host.http.stream_close" => { - if !payload.is_empty() { - return Err(invalid()); + "host.http.stream_close" => { + if !payload.is_empty() { + return Err(invalid()); + } + let request: wire::StreamClose = decode(params)?; + let stream = call + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .streams + .remove(&request.stream) + .ok_or_else(denied)?; + stream.close(); + Ok(RpcReply { + result: serde_json::json!({}), + payload: vec![], + }) } - let request: wire::StreamClose = decode(params)?; - let stream = call - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .streams - .remove(&request.stream) - .ok_or_else(denied)?; - stream.close(); - Ok(RpcReply { - result: serde_json::json!({}), - payload: vec![], - }) + _ => Err(denied()), } - _ => Err(denied()), } } @@ -177,7 +202,7 @@ fn sent_fault(code: ErrorCode, message: &'static str) -> PluginFault { error } -fn http_error(error: HttpError) -> PluginFault { +pub(super) fn http_error(error: HttpError) -> PluginFault { tracing::warn!( reason = error.reason(), kind = ?error.kind(), diff --git a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs index a01806c04..37d50b866 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs @@ -10,6 +10,7 @@ mod model; pub(crate) mod private_state; mod resources; mod scope; +pub(crate) mod upstream; use std::{ collections::BTreeMap, @@ -87,6 +88,7 @@ pub(crate) struct PluginCallbacks { struct HttpAuthorization { network: NetworkPolicy, authorized: bool, + upstream_authorized: bool, } struct CallResources { @@ -155,6 +157,9 @@ impl PluginCallbacks { http_authorization: Arc::new(HttpAuthorization { network: ports.network, authorized: grants.iter().any(|grant| grant.permission == "network"), + upstream_authorized: grants + .iter() + .any(|grant| grant.permission == "upstream_connections"), }), scopes: Mutex::new(BTreeMap::new()), pending_middleware: Mutex::new(BTreeMap::new()), @@ -218,6 +223,25 @@ impl PluginCallbacks { ) } + pub(crate) fn prepare_upstream( + &self, + context: &CallContext, + managed: Arc, + extension_scope: gateway_core::engine::extensions::ExtensionCallScope, + ) -> Result, PluginFault> { + if context.stage != gateway_plugin_sdk::Stage::Upstream + || !self.http_authorization.upstream_authorized + || context.account_id.as_deref() != Some(managed.account.account_id().as_str()) + || context.credential_revision != Some(managed.account.credential_revision().get()) + { + return Err(denied()); + } + let mut scope = NetworkScope::new(context, managed.account.outbound_proxy().cloned()) + .with_upstream(managed); + scope.extension_scope = extension_scope; + self.register_scope(context, scope) + } + pub(crate) fn prepare_management( &self, context: &CallContext, @@ -480,20 +504,25 @@ impl CallbackHandler for PluginCallbacks { .call(&context, scope, &method, params, &payload) .await; } + let http = http::HttpCallbacks { + client: &http, + authorization: &authorization, + scope, + call: &call, + maximum_payload, + }; + if method.starts_with("host.upstream.") { + if !authorization.upstream_authorized + || context.stage != gateway_plugin_sdk::Stage::Upstream + { + return Err(denied()); + } + return upstream::dispatch(&http, &method, params, payload).await; + } if !authorization.authorized { return Err(denied()); } - http::dispatch( - &http, - &authorization, - scope, - &call, - &method, - params, - payload, - maximum_payload, - ) - .await + http.dispatch(&method, params, payload).await }) } } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/scope.rs b/backend/crates/gateway-plugin/runtime/src/callback/scope.rs index 3301b44fa..56c742e68 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/scope.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/scope.rs @@ -13,6 +13,7 @@ pub(crate) struct NetworkScope { pub(super) proxy: Option, pub(super) extension_scope: gateway_core::engine::extensions::ExtensionCallScope, execution_effects: Option>, + pub(super) upstream: Option>, } impl NetworkScope { @@ -24,6 +25,7 @@ impl NetworkScope { proxy, extension_scope: Default::default(), execution_effects: None, + upstream: None, } } @@ -36,6 +38,12 @@ impl NetworkScope { self } + pub(super) fn with_upstream(mut self, managed: Arc) -> Self { + self.execution_effects = managed.effects.clone(); + self.upstream = Some(managed); + self + } + pub(super) fn account_id(&self) -> Option<&str> { self.account_id.as_deref() } @@ -50,9 +58,22 @@ impl NetworkScope { && context.credential_revision == self.credential_revision } - pub(super) fn start_http(&self) -> HttpAttempt { + pub(super) fn start_upstream( + &self, + purpose: Option, + ) -> HttpAttempt { HttpAttempt { - effects: self.execution_effects.clone(), + effects: if purpose + == Some(gateway_plugin_sdk::call::upstream_adapter::UpstreamPathPurpose::Inference) + { + None + } else { + self.execution_effects.clone() + }, + upstream: self + .upstream + .as_ref() + .map(|managed| Arc::clone(&managed.send_state)), completed: false, } } @@ -61,6 +82,7 @@ impl NetworkScope { pub(super) struct HttpAttempt { effects: Option>, completed: bool, + upstream: Option>, } impl HttpAttempt { @@ -70,6 +92,9 @@ impl HttpAttempt { } fn observe(&self, observed: UpstreamSendState) { + if let Some(upstream) = &self.upstream { + upstream.observe(observed); + } if observed != UpstreamSendState::NotSent && let Some(effects) = &self.effects { diff --git a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs new file mode 100644 index 000000000..cf5162791 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs @@ -0,0 +1,331 @@ +mod sessions; + +use std::{ + sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }, + time::Duration, +}; + +use gateway_core::{ + engine::{nested::ExecutionEffects, upstream_adapter::UpstreamAccountConnection}, + upstream::UpstreamSendState, +}; +use gateway_host::outbound::{HttpRequest, ManagedWebSocket, WebSocketMessage}; +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::{ + host as wire, + upstream_adapter::{self as adapter, UpstreamPathPurpose}, + }, +}; + +use super::{denied, http::HttpCallbacks, invalid}; +use crate::{ + RpcReply, + adapter::upstream_adapter::target::{UpstreamTarget, protected_header}, +}; + +pub(crate) use sessions::{ConnectionOwner, ConnectionPool}; + +#[derive(Default)] +pub(crate) struct SendWatermark(AtomicU8); + +impl SendWatermark { + pub(crate) fn observe(&self, state: UpstreamSendState) { + self.0.fetch_max( + match state { + UpstreamSendState::NotSent => 0, + UpstreamSendState::Sent => 1, + UpstreamSendState::Ambiguous => 2, + }, + Ordering::AcqRel, + ); + } + + pub(crate) fn get(&self) -> UpstreamSendState { + match self.0.load(Ordering::Acquire) { + 0 => UpstreamSendState::NotSent, + 1 => UpstreamSendState::Sent, + _ => UpstreamSendState::Ambiguous, + } + } +} + +pub(crate) struct ManagedUpstream { + pub(crate) account: Arc, + pub(crate) send_state: Arc, + pub(crate) effects: Option>, + target: Arc, + socket: tokio::sync::Mutex>, + pool: Arc, + owner: ConnectionOwner, + websocket_allowed: bool, +} + +impl ManagedUpstream { + pub(crate) fn new( + account: Arc, + target: Arc, + effects: Option>, + pool: Arc, + owner: ConnectionOwner, + connection: Option<&str>, + websocket_allowed: bool, + ) -> Result, PluginFault> { + let socket = connection.map(|id| pool.take(id, &owner)).transpose()?; + Ok(Arc::new(Self { + account, + target, + effects, + pool, + owner, + websocket_allowed, + send_state: Arc::default(), + socket: tokio::sync::Mutex::new(socket), + })) + } + + pub(crate) async fn retain_connection(&self) -> Result { + let socket = self.socket.lock().await.take().ok_or_else(denied)?; + self.pool.put(self.owner.clone(), socket) + } + + fn request( + &self, + request: adapter::UpstreamHttpRequest, + body: Vec, + ) -> Result<(HttpRequest, UpstreamPathPurpose), PluginFault> { + let (target, purpose) = self.target.resolve(&request.path, &request.query)?; + if request.headers.len() > 128 + || request + .headers + .iter() + .any(|(name, _)| protected_header(name)) + { + return Err(denied()); + } + let authorization = self.account.authorization().map_err(|_| { + PluginFault::new( + ErrorCode::Rejected, + "selected account authentication is unavailable", + ) + })?; + if request.headers.iter().any(|(name, _)| { + authorization + .iter() + .any(|header| name.eq_ignore_ascii_case(header.name())) + }) { + return Err(denied()); + } + let headers = request + .headers + .into_iter() + .map(|(name, value)| (name, value.into_bytes())) + .chain(authorization.into_iter().map(|header| { + let (name, value) = header.into_parts(); + (name, value.to_vec()) + })) + .collect(); + Ok(( + HttpRequest { + method: request.method, + url: target.into(), + headers, + body, + }, + purpose, + )) + } +} + +pub(super) async fn dispatch( + http: &HttpCallbacks<'_>, + method: &str, + params: serde_json::Value, + payload: Vec, +) -> Result { + let HttpCallbacks { + client, + authorization, + scope, + call, + maximum_payload, + } = *http; + let managed = scope.upstream.as_ref().ok_or_else(denied)?; + let timeout = || { + call.deadline + .checked_duration_since(tokio::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .map(|remaining| remaining.min(Duration::from_secs(120))) + .ok_or_else(|| PluginFault::new(ErrorCode::Timeout, "upstream deadline elapsed")) + }; + match method { + "host.upstream.http.do" | "host.upstream.http.do_stream" => { + let request = serde_json::from_value(params).map_err(|_| invalid())?; + let (request, purpose) = managed.request(request, payload)?; + http.send(request, method.ends_with("do_stream"), Some(purpose)) + .await + } + "host.upstream.http.stream_read" | "host.upstream.http.stream_close" => { + http.dispatch( + if method.ends_with("stream_read") { + "host.http.stream_read" + } else { + "host.http.stream_close" + }, + params, + payload, + ) + .await + } + "host.upstream.websocket.open" => { + if !payload.is_empty() || !managed.websocket_allowed { + return Err(denied()); + } + let request: adapter::UpstreamWebSocketRequest = + serde_json::from_value(params).map_err(|_| invalid())?; + let (request, purpose) = managed.request( + adapter::UpstreamHttpRequest { + method: "GET".into(), + path: request.path, + query: request.query, + headers: request.headers, + }, + vec![], + )?; + if purpose != UpstreamPathPurpose::Inference { + return Err(denied()); + } + let mut socket = managed.socket.try_lock().map_err(|_| denied())?; + if let Some((url, _)) = socket.as_ref() { + if *url != request.url { + return Err(denied()); + } + return Ok(RpcReply { + result: serde_json::json!({"status":101,"headers":[],"stream":null}), + payload: vec![], + }); + } + let url = request.url.clone(); + let attempt = scope.start_upstream(Some(UpstreamPathPurpose::Inference)); + let response = client + .open_websocket( + request, + managed.account.outbound_proxy(), + &authorization.network, + timeout()?, + ) + .await; + // 握手也已向上游发送账号身份;失败不能伪装成未出站或跳过账号反馈。 + attempt.finish( + response + .as_ref() + .map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent), + ); + let mut response = response.map_err(super::http::http_error)?; + let headers = response + .headers + .into_iter() + .map(|(name, value)| String::from_utf8(value).map(|value| (name, value))) + .collect::, _>>() + .map_err(|_| invalid())?; + let mut payload = Vec::new(); + if let Some(body) = response.body.as_mut() { + while let Some(chunk) = body + .read(64 * 1024) + .await + .map_err(super::http::http_error)? + { + if payload.len() + chunk.len() > maximum_payload { + return Err(invalid()); + } + payload.extend_from_slice(&chunk); + } + } + if let Some(connection) = response.connection { + *socket = Some((url, connection)); + } + Ok(RpcReply { + result: serde_json::to_value(wire::HttpResponse { + status: response.status, + headers, + stream: None, + }) + .map_err(|_| invalid())?, + payload, + }) + } + "host.upstream.websocket.send" => { + let message: adapter::UpstreamWebSocketMessage = + serde_json::from_value(params).map_err(|_| invalid())?; + let message = match message.kind { + adapter::WebSocketMessageKind::Text => { + WebSocketMessage::Text(String::from_utf8(payload).map_err(|_| invalid())?) + } + adapter::WebSocketMessageKind::Binary => WebSocketMessage::Binary(payload.into()), + }; + let mut socket = managed.socket.try_lock().map_err(|_| denied())?; + let (_, socket) = socket.as_mut().ok_or_else(denied)?; + let attempt = scope.start_upstream(Some(UpstreamPathPurpose::Inference)); + let result = socket.send(message, timeout()?).await; + attempt.finish( + result + .as_ref() + .map_or_else(|error| error.send_state, |()| UpstreamSendState::Sent), + ); + result.map_err(super::http::http_error)?; + Ok(RpcReply { + result: serde_json::json!({}), + payload: vec![], + }) + } + "host.upstream.websocket.read" => { + if params != serde_json::json!({}) || !payload.is_empty() { + return Err(invalid()); + } + let mut socket = managed.socket.try_lock().map_err(|_| denied())?; + let (_, connection) = socket.as_mut().ok_or_else(denied)?; + let result = connection + .read(timeout()?) + .await + .map_err(super::http::http_error)?; + let (kind, payload) = match result { + Some(WebSocketMessage::Text(text)) => { + (Some(adapter::WebSocketMessageKind::Text), text.into_bytes()) + } + Some(WebSocketMessage::Binary(bytes)) => { + (Some(adapter::WebSocketMessageKind::Binary), bytes.to_vec()) + } + None => { + socket.take(); + (None, vec![]) + } + }; + if payload.len() > maximum_payload { + socket.take(); + return Err(invalid()); + } + Ok(RpcReply { + result: serde_json::to_value(adapter::UpstreamWebSocketRead { + eof: kind.is_none(), + kind, + }) + .map_err(|_| invalid())?, + payload, + }) + } + "host.upstream.websocket.close" => { + if params != serde_json::json!({}) || !payload.is_empty() { + return Err(invalid()); + } + managed.socket.try_lock().map_err(|_| denied())?.take(); + Ok(RpcReply { + result: serde_json::json!({}), + payload: vec![], + }) + } + _ => Err(denied()), + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/upstream/sessions.rs b/backend/crates/gateway-plugin/runtime/src/callback/upstream/sessions.rs new file mode 100644 index 000000000..21a720528 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/upstream/sessions.rs @@ -0,0 +1,101 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use gateway_host::outbound::ManagedWebSocket; +use gateway_plugin_sdk::{ErrorCode, PluginFault}; + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct ConnectionOwner { + pub client_key_id: String, + pub account_id: String, + pub credential_revision: u64, +} + +/// 每个适配器代次独立持有连接;取出即独占,失败或取消不会归还半完成会话。 +#[derive(Default)] +pub(crate) struct ConnectionPool { + idle: Mutex>, +} + +struct IdleConnection { + owner: ConnectionOwner, + socket: Option<(String, ManagedWebSocket)>, + expires: tokio::task::JoinHandle<()>, +} + +impl Drop for IdleConnection { + fn drop(&mut self) { + self.expires.abort(); + } +} + +impl ConnectionPool { + pub(crate) fn take( + &self, + id: &str, + owner: &ConnectionOwner, + ) -> Result<(String, ManagedWebSocket), PluginFault> { + let mut idle = self + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !idle + .get(id) + .is_some_and(|connection| connection.owner == *owner) + { + return Err(PluginFault::new( + ErrorCode::Rejected, + "upstream continuation connection is unavailable", + )); + } + idle.remove(id) + .and_then(|mut connection| connection.socket.take()) + .ok_or_else(|| { + PluginFault::new( + ErrorCode::Rejected, + "upstream continuation connection is unavailable", + ) + }) + } + + pub(crate) fn put( + self: &Arc, + owner: ConnectionOwner, + socket: (String, ManagedWebSocket), + ) -> Result { + let mut idle = self + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if idle.len() >= 64 { + return Err(PluginFault::new( + ErrorCode::Capacity, + "upstream continuation capacity exhausted", + )); + } + let id = uuid::Uuid::new_v4().to_string(); + let weak = Arc::downgrade(self); + let expired_id = id.clone(); + let expires = tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30 * 60)).await; + if let Some(pool) = weak.upgrade() { + pool.idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&expired_id); + } + }); + idle.insert( + id.clone(), + IdleConnection { + owner, + socket: Some(socket), + expires, + }, + ); + Ok(id) + } +} 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 259ba7083..8403a7196 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs @@ -57,6 +57,10 @@ impl PluginRuntime { &instance.bindings, )?; crate::adapter::policy::validate_bindings(package.manifest(), &instance.bindings)?; + crate::adapter::upstream_adapter::validate_bindings( + package.manifest(), + &instance.bindings, + )?; crate::adapter::frontend_authentication::validate_bindings( package.manifest(), &instance.bindings, @@ -235,6 +239,13 @@ impl PluginRuntime { )?); let model_aliases = crate::adapter::catalog::prepare(&manifest, &instance, &session).await?; + let upstream_entries = crate::adapter::upstream_adapter::prepare( + &manifest, + &instance, + Arc::clone(&session), + Arc::clone(&callbacks), + ) + .await?; sessions.push(PreparedInstance { instance_id, artifact_sha256: instance.artifact_sha256, @@ -251,6 +262,7 @@ impl PluginRuntime { commands, management, policy_entries, + upstream_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 3cc3bef5d..b22e14b8c 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs @@ -120,6 +120,7 @@ pub(super) struct PreparedSet { _observers: Option>, _policies: Option>, _middleware: Option>, + upstream_adapters: Option>, _authentication: Option>, sessions: Vec, @@ -137,6 +138,7 @@ struct PreparedContributions { commands: Vec>, management: Vec, policy_entries: Vec, + upstream_entries: Vec, authentication_entries: Vec, model_aliases: Vec, @@ -150,6 +152,7 @@ impl PreparedContributions { self.management.extend(other.management); self.model_aliases.extend(other.model_aliases); self.policy_entries.extend(other.policy_entries); + self.upstream_entries.extend(other.upstream_entries); self.authentication_entries .extend(other.authentication_entries); } @@ -173,6 +176,12 @@ impl PreparedSet { } impl ExtensionSetLease for PreparedSet { + fn upstream_adapters( + &self, + ) -> Option> { + self.upstream_adapters.clone() + } + fn is_ready(&self) -> bool { self.sessions_ready() && self.can_serve() } @@ -456,6 +465,9 @@ impl PluginRuntime { contributions .policy_entries .extend(crate::adapter::policy::unavailable_entries(instance)?); + contributions.upstream_entries.extend( + crate::adapter::upstream_adapter::unavailable_entries(instance)?, + ); if let Some(entry) = crate::adapter::frontend_authentication::unavailable_entry(instance) { @@ -471,6 +483,7 @@ impl PluginRuntime { commands, management, policy_entries, + upstream_entries, authentication_entries, model_aliases, } = contributions; @@ -535,6 +548,10 @@ impl PluginRuntime { _observers: observers, _policies: policies, _middleware: middleware, + upstream_adapters: + crate::adapter::upstream_adapter::PluginUpstreamAdapterPlan::compile( + upstream_entries, + )?, _authentication: authentication, sessions, commands, @@ -865,6 +882,10 @@ impl PluginPreparation for PluginRuntime { 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::upstream_adapter::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/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs index 6799a8400..2db825a74 100644 --- a/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/src/package/compatibility.rs @@ -4,7 +4,7 @@ use gateway_admin::model::{ AdminError, plugins::{PluginCompatibilityRequirements, PluginHostCompatibility}, }; -use gateway_plugin_sdk::Manifest; +use gateway_plugin_sdk::{Capability, MANIFEST_VERSION, Manifest, PROTOCOL_VERSION, Permission}; const HOST_COMPATIBILITY_JSON: &str = include_str!("../../plugin-host-compatibility.json"); @@ -15,7 +15,31 @@ pub(crate) fn host_compatibility() -> Result<&'static PluginHostCompatibility, A let compatibility = serde_json::from_str::(HOST_COMPATIBILITY_JSON) .map_err(|_| ())?; - compatibility.is_valid().then_some(compatibility).ok_or(()) + // 发行声明可以是 SDK 合同的子集,但不能声称支持当前二进制无法解释的合同。 + let known_contracts = compatibility + .manifest_schema_versions + .iter() + .all(|version| *version == MANIFEST_VERSION) + && compatibility + .protocol_versions + .iter() + .all(|version| *version == PROTOCOL_VERSION) + && compatibility.capabilities.iter().all(|entry| { + serde_json::from_value::(entry.capability.clone().into()).is_ok_and( + |capability| { + entry + .versions + .iter() + .all(|version| capability.contract_versions().contains(version)) + }, + ) + }) + && compatibility.permissions.iter().all(|permission| { + serde_json::from_value::(permission.clone().into()).is_ok() + }); + (compatibility.is_valid() && known_contracts) + .then_some(compatibility) + .ok_or(()) }) .as_ref() .map_err(|()| AdminError::internal("宿主插件兼容声明不合法")) @@ -35,13 +59,15 @@ pub(crate) fn requirements( capabilities: manifest .contributes .iter() - .map(|(capability, declaration)| Ok((identifier(*capability)?, declaration.version))) - .collect::>()?, + .map(|(capability, declaration)| { + (capability.identifier().to_owned(), declaration.version) + }) + .collect(), permissions: manifest .permissions .iter() - .map(|permission| identifier(*permission)) - .collect::>()?, + .map(|permission| permission.as_str().to_owned()) + .collect(), }) } @@ -63,10 +89,3 @@ pub(crate) fn supports(manifest: &Manifest) -> Result { .iter() .all(|permission| compatibility.supports_permission(permission))) } - -fn identifier(value: impl serde::Serialize) -> Result { - serde_json::to_value(value) - .ok() - .and_then(|value| value.as_str().map(str::to_owned)) - .ok_or_else(|| AdminError::internal("插件描述转换失败")) -} diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs index b732cef51..bc67b9554 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs @@ -253,6 +253,18 @@ fn callback_allowed(method: &str, stage: Stage, permissions: &[Permission]) -> b ) && permissions.contains(&Permission::KeyBudgets)); } let permission = match method { + "host.upstream.http.do" + | "host.upstream.http.do_stream" + | "host.upstream.http.stream_read" + | "host.upstream.http.stream_close" + | "host.upstream.websocket.open" + | "host.upstream.websocket.send" + | "host.upstream.websocket.read" + | "host.upstream.websocket.close" + if stage == Stage::Upstream => + { + Permission::UpstreamConnections + } "host.http.do" | "host.http.do_stream" | "host.http.stream_read" diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index 12b0d8bde..b9a6e8374 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -417,6 +417,10 @@ impl Shared { } impl RpcSession { + pub(crate) const fn maximum_call_timeout(&self) -> Duration { + self.limits.maximum_call_timeout + } + pub async fn start( package: Arc, handshake: Handshake, diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs index 9d6f2dcd4..11fe2a396 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs @@ -6,6 +6,7 @@ mod observer; mod policy; mod prepare; mod private_state; +mod upstream_adapter; use gateway_admin::{ model::{ diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs new file mode 100644 index 000000000..89d8e3be6 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs @@ -0,0 +1,792 @@ +use std::{ + num::NonZeroU32, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, SystemTime}, +}; + +use bytes::Bytes; +use futures::{StreamExt as _, future::BoxFuture}; +use gateway_admin::model::plugins::instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginPermissionGrant, +}; +use gateway_core::{ + account::{AccountSelectionPolicy, CredentialRevision, OutboundProxy, ProviderAccountId}, + engine::{ + AccountAttemptContext, AttemptContext, ModelRequestId, RequestAttemptContext, + middleware::MiddlewareHeader, + provider::{EventStream, ProviderCallMetadata}, + upstream_adapter::{UpstreamAccountConnection, UpstreamAdapterInvocation}, + }, + error::{ProviderError, ProviderErrorKind}, + event::GatewayEvent, + identity::ProviderKind, + lifecycle::CancellationToken, + metering::{CalculatedCost, Usage}, + operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState}, + policy::ClientApiKeyId, + routing::{ConfigRevision, UpstreamModelId}, + runtime::extensions::{ExtensionPreparationPort, ExtensionSetReference}, + upstream::{UpstreamSendState, UpstreamTransport}, +}; +use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use serde_json::{Value, json}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, +}; + +struct Account { + id: ProviderAccountId, + revision: u64, + failures: AtomicUsize, +} +impl Account { + fn new() -> Arc { + Self::with_identity("acct_one", 1) + } + fn with_identity(id: &str, revision: u64) -> Arc { + Arc::new(Self { + id: ProviderAccountId::new(id).unwrap(), + revision, + failures: AtomicUsize::new(0), + }) + } +} +impl UpstreamAccountConnection for Account { + fn account_id(&self) -> &ProviderAccountId { + &self.id + } + fn credential_revision(&self) -> CredentialRevision { + CredentialRevision::new(self.revision).unwrap() + } + fn authentication_kind(&self) -> &str { + "oauth" + } + fn outbound_proxy(&self) -> Option<&OutboundProxy> { + None + } + fn authorization(&self) -> Result, ProviderError> { + Ok(vec![MiddlewareHeader::new( + "authorization", + Bytes::from_static(b"Bearer fixture-native-token"), + )]) + } + fn calculate_cost(&self, _: Option<&str>, usage: &Usage) -> Option { + assert_eq!(usage.input_tokens, Some(7)); + CalculatedCost::from_usd_ticks(123).ok() + } + fn record_failure(&self, error: ProviderError) -> BoxFuture<'_, ProviderError> { + self.failures.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { error }) + } +} + +fn configuration(base: &str, streaming: bool) -> Value { + json!({ + "upstream_registration":{"adapters":[{"id":"example","provider":"openai","base_url":format!("{base}/adapter/"),"paths":[{"path":"responses","purpose":"inference"}],"authentication_kinds":["oauth"],"transport":if streaming {"http_sse"} else {"http_json"},"protocol":"openai"}]}, + "upstream_callbacks":[{"method":if streaming {"host.upstream.http.do_stream"} else {"host.upstream.http.do"},"params":{"method":"POST","path":"responses","headers":[["content-type","application/json"]]},"body":"request"}], + "upstream_events":[ + {"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"}]}}, + {"event":{"facts":[{"type":"usage","usage":{"input_tokens":7,"output_tokens":2}},{"type":"completed","id":"response-one","model":"native-model","reason":"stop"}]},"wire":if streaming {"sse"} else {"http"},"continuation":{"scope":"persisted","upstream_response_id":"private-upstream-id","state":{"cursor":"opaque"}}} + ] + }) +} + +async fn setup( + config: Value, +) -> ( + tempfile::TempDir, + Arc, + gateway_plugin_runtime::PluginRuntime, +) { + let permissions = vec![Permission::Requests, Permission::UpstreamConnections]; + let contributes = Contributions::from([crate::support::contribution( + Capability::UpstreamAdapter, + vec![Stage::Upstream], + vec!["openai".into()], + vec!["openai".into()], + )]); + let (cache, store, runtime) = + super::setup_with_permissions(contributes, Default::default(), permissions.clone()).await; + { + let mut snapshot = store.snapshot.lock().unwrap(); + let instance = &mut snapshot.instances[0]; + instance.configuration = config; + instance.grants = permissions + .into_iter() + .map(|permission| PluginPermissionGrant { + permission: permission.as_str().into(), + }) + .collect(); + instance.bindings = vec![PluginCapabilityBinding { + contribution: "test.example.upstreamAdapter".into(), + stage: "upstream".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec!["openai".into()], + models: vec![], + identity_bindings: vec![], + }]; + } + ( + cache, + store, + runtime.with_network_policy( + gateway_host::outbound::NetworkPolicy::new(&["127.0.0.0/8".into()]).unwrap(), + ), + ) +} + +fn context(generation: &ExtensionSetReference, key: &str) -> AttemptContext { + AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_one").unwrap(), + ClientApiKeyId::new(key).unwrap(), + ) + .with_upstream_adapters(generation.upstream_adapters()), + NonZeroU32::new(1).unwrap(), + SystemTime::now() + Duration::from_secs(5), + AccountSelectionPolicy::new( + gateway_core::account::RotationStrategy::RoundRobin, + std::num::NonZeroU32::new(1).unwrap(), + Duration::ZERO, + ), + AccountAttemptContext::new(Default::default(), None, None), + None, + CancellationToken::new(), + ) +} + +fn execute( + generation: &ExtensionSetReference, + account: Arc, + key: &str, + previous: Option, +) -> EventStream { + let context = context(generation, key); + let provider = ProviderKind::new("openai").unwrap(); + let model = UpstreamModelId::new("native-model").unwrap(); + let adapter = context + .upstream_adapter(&provider, &model) + .unwrap() + .unwrap(); + let metadata = ProviderCallMetadata::new( + provider, + model, + account.id.clone(), + UpstreamTransport::new(adapter.transport()).unwrap(), + ); + let mut operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"native-model","input":"hello"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(), + )); + if let Some(previous) = previous { + operation = operation.with_provider_session_state(previous); + } + adapter.execute(UpstreamAdapterInvocation { + operation, + headers: vec![], + context, + metadata, + account, + }) +} + +#[tokio::test] +async fn adapter_http_and_sse_are_cold_authenticated_and_settled_once() { + for streaming in [false, true] { + let server = MockServer::start().await; + let body = if streaming { + "data: {\"id\":\"response-one\"}\n\n" + } else { + "{\"id\":\"response-one\"}" + }; + Mock::given(method("POST")) + .and(path("/adapter/responses")) + .and(header("authorization", "Bearer fixture-native-token")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + let (cache, _, runtime) = setup(configuration(&server.uri(), streaming)).await; + let generation = + ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let account = Account::new(); + let mut stream = execute(&generation, Arc::clone(&account), "key-one", None); + assert!( + server.received_requests().await.unwrap().is_empty(), + "构造冷流不能先发送业务请求" + ); + let mut costs = 0; + let mut completed = 0; + let mut state = None; + while let Some(event) = stream.next().await { + let mut event = event.unwrap(); + for fact in event.canonical_facts() { + costs += usize::from(matches!(fact, GatewayEvent::CalculatedCost(_))); + completed += usize::from(matches!(fact, GatewayEvent::Completed(_))); + } + if event.session_update().is_some() { + state = event.take_session_update(); + } + } + assert_eq!((costs, completed), (1, 1)); + let state = state.unwrap(); + assert_eq!(state.extension_owner().unwrap().adapter_id, "example"); + assert_eq!(account.failures.load(Ordering::SeqCst), 0); + drop(stream); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + } +} + +#[tokio::test] +async fn adapter_accepts_service_tier_resolved_by_the_terminal_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let mut config = configuration(&server.uri(), false); + config["upstream_events"][0]["service_tier"] = json!("auto"); + config["upstream_events"][1]["service_tier"] = json!("default"); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&generation, Account::new(), "key-one", None); + let mut tiers = Vec::new(); + let mut completed = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + tiers.push( + event + .response_observation() + .unwrap() + .service_tier() + .unwrap() + .to_owned(), + ); + completed |= event + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Completed(_))); + } + assert_eq!(tiers, ["auto", "default"]); + assert!(completed); + drop(stream); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(429).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let mut config = configuration(&server.uri(), false); + config["upstream_events"] = json!([{ + "event": {"facts": []}, + "failure": { + "kind": "rate_limited", "status": 429, "retry_after_ms": 1500, + "message": "fixture rate limit", "code": "rate_limit_exceeded" + } + }]); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let account = Account::new(); + let mut stream = execute(&generation, Arc::clone(&account), "key-one", None); + let error = stream.next().await.unwrap().unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::RateLimited); + assert_eq!(error.send_state(), UpstreamSendState::Sent); + assert_eq!(error.upstream_status(), Some(429)); + assert_eq!( + error.upstream_code().unwrap().as_str(), + "rate_limit_exceeded" + ); + assert_eq!(error.retry_after(), Some(Duration::from_millis(1500))); + assert_eq!(account.failures.load(Ordering::SeqCst), 1); + drop(stream); + assert_eq!(Arc::strong_count(&account), 1); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn adapter_rejects_forged_auth_headers_and_cross_key_continuation_before_send() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let (cache, _, runtime) = setup(configuration(&server.uri(), false)).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&generation, Account::new(), "key-one", None); + let mut state = None; + while let Some(event) = stream.next().await { + state = event.unwrap().take_session_update().or(state); + } + drop(stream); + for (key, account) in [ + ("key-two", Account::new()), + ("key-one", Account::with_identity("acct_other", 1)), + ("key-one", Account::with_identity("acct_one", 2)), + ] { + let error = execute(&generation, account, key, state.clone()) + .next() + .await + .unwrap() + .err() + .unwrap(); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + } + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + + let mut config = configuration(&server.uri(), false); + config["upstream_callbacks"][0]["params"]["headers"] = json!([["Authorization", "injected"]]); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let error = execute(&generation, Account::new(), "key-one", None) + .next() + .await + .unwrap() + .err() + .unwrap(); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn adapter_does_not_publish_completed_when_rpc_fails_after_terminal_frame() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .mount(&server) + .await; + let mut config = configuration(&server.uri(), false); + config["upstream_tail_error"] = json!(true); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&generation, Account::new(), "key-one", None); + stream.next().await.unwrap().unwrap(); + let error = stream.next().await.unwrap().err().unwrap(); + assert_eq!(error.kind(), ProviderErrorKind::Unavailable); + assert_eq!(error.send_state(), UpstreamSendState::Sent); + let mut error = error; + let facts = error + .take_atomic_client_events() + .into_iter() + .flat_map(|event| event.into_parts().0) + .collect::>(); + assert_eq!(facts.len(), 2); + assert!(matches!(facts[0], GatewayEvent::Usage(_))); + assert!(matches!(facts[1], GatewayEvent::CalculatedCost(_))); + drop(stream); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn adapter_registration_rejects_target_escape_and_custom_providers() { + for invalid in [ + "../responses", + "https://elsewhere.invalid/responses", + "responses?other=1", + "%2e%2e/responses", + "/responses", + ] { + let mut config = configuration("https://example.invalid", false); + config["upstream_registration"]["adapters"][0]["paths"][0]["path"] = json!(invalid); + let (_, store, runtime) = setup(config).await; + let snapshot = store.snapshot.lock().unwrap().clone(); + assert!( + gateway_admin::ports::plugins::PluginPreparation::prepare(&runtime, snapshot) + .await + .is_err() + ); + runtime.shutdown().await; + } + let mut config = configuration("https://example.invalid", false); + config["upstream_registration"]["adapters"][0]["provider"] = json!("custom"); + let (_, store, runtime) = setup(config).await; + let snapshot = store.snapshot.lock().unwrap().clone(); + assert!( + gateway_admin::ports::plugins::PluginPreparation::prepare(&runtime, snapshot) + .await + .is_err() + ); + runtime.shutdown().await; +} + +#[tokio::test] +#[expect( + clippy::result_large_err, + reason = "握手回调的错误类型由 tungstenite 固定" +)] +async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle_once() { + use futures::SinkExt as _; + use tokio_tungstenite::tungstenite::Message; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_hdr_async( + socket, + |request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| { + assert_eq!( + request.headers()["authorization"], + "Bearer fixture-native-token" + ); + Ok(response) + }, + ) + .await + .unwrap(); + for _ in 0..2 { + assert!(matches!( + ws.next().await.unwrap().unwrap(), + Message::Text(_) + )); + ws.send(Message::Text("{}".into())).await.unwrap(); + } + assert!(ws.next().await.is_none_or(|result| result.is_err())); + }); + let mut config = configuration(&base, false); + config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket"); + config["upstream_events"][1]["continuation"]["scope"] = json!("connection_local"); + config["upstream_callbacks"] = json!([ + {"method":"host.upstream.websocket.open","params":{"path":"responses","headers":[]}}, + {"method":"host.upstream.websocket.send","params":{"kind":"text"},"body":"request"}, + {"method":"host.upstream.websocket.read","params":{}} + ]); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut first = execute(&generation, Account::new(), "key-one", None); + let mut state = None; + while let Some(event) = first.next().await { + state = event.unwrap().take_session_update().or(state); + } + drop(first); + let state = state.unwrap(); + assert!(state.extension_owner().unwrap().connection_local); + let error = execute(&generation, Account::new(), "key-two", Some(state.clone())) + .next() + .await + .unwrap() + .err() + .unwrap(); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + let mut second = execute(&generation, Account::new(), "key-one", Some(state.clone())); + while let Some(event) = second.next().await { + event.unwrap(); + } + drop(second); + let error = execute(&generation, Account::new(), "key-one", Some(state)) + .next() + .await + .unwrap() + .err() + .unwrap(); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn cancelling_adapter_body_closes_pending_websocket_and_releases_selected_account() { + use tokio_tungstenite::tungstenite::Message; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (sent, received) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + assert!(matches!( + socket.next().await.unwrap().unwrap(), + Message::Text(_) + )); + sent.send(()).unwrap(); + assert!(socket.next().await.is_none_or(|result| result.is_err())); + }); + let mut config = configuration(&base, false); + config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket"); + config["upstream_callbacks"] = json!([ + {"method":"host.upstream.websocket.open","params":{"path":"responses","headers":[]}}, + {"method":"host.upstream.websocket.send","params":{"kind":"text"},"body":"request"}, + {"method":"host.upstream.websocket.read","params":{}} + ]); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let account = Account::new(); + let mut stream = execute(&generation, account.clone(), "key-one", None); + let mut reading = Box::pin(stream.next()); + tokio::select! { + result = &mut reading => panic!("上游等待期间不应提前产生结果:{}", result.is_some()), + result = tokio::time::timeout(Duration::from_secs(3), received) => result.unwrap().unwrap(), + } + drop(reading); + drop(stream); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while Arc::strong_count(&account) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn usage_without_response_model_still_uses_native_account_pricing() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .mount(&server) + .await; + let mut config = configuration(&server.uri(), false); + config["upstream_events"][0]["event"]["facts"][0]["model"] = Value::Null; + config["upstream_events"][1]["event"]["facts"][1]["model"] = Value::Null; + let (_, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&generation, Account::new(), "key-one", None); + let mut costs = 0; + while let Some(event) = stream.next().await { + costs += event + .unwrap() + .canonical_facts() + .iter() + .filter(|fact| matches!(fact, GatewayEvent::CalculatedCost(_))) + .count(); + } + assert_eq!(costs, 1); + drop(stream); + drop(generation); + runtime.shutdown().await; +} + +#[tokio::test] +async fn core_published_adapter_uses_normal_attempt_usage_and_cost_ledger() { + use crate::support::environment::{Environment, account_grant}; + let Some(mut environment) = Environment::create_command().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(header("authorization", "Bearer fixture-native-token")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let account = environment.account(None).await; + let group = environment.account_group_with_account(&account).await; + let key_id = "key_adapter_ledger"; + environment + .client_key_with_limits_and_groups( + key_id, + "sk-adapter-ledger-fixture", + gateway_core::policy::RateLimits::unlimited(), + vec![group], + ) + .await; + environment + .install_plugin( + configuration(&server.uri(), false), + vec![ + account_grant("requests"), + account_grant("upstream_connections"), + ], + ) + .await; + environment.install_plugin(json!({ + "plugin_id":"test.adapter-ledger-caller", + "command_registration":{"commands":[{"name":"run","description":"执行模型"}]}, + "command_nested_model_fixture":{ + "request":{"client_key_id":key_id,"model":crate::support::native::MODEL,"protocol":"openai","operation":"generate","provider":"openai","account_id":account.as_str()}, + "body":{"model":crate::support::native::MODEL,"input":"adapter ledger"} + }, + "command_result":{"stdout":"ok","stderr":"","exit_code":0} + }), vec![account_grant("models"), account_grant("accounts")]).await; + let (runtime, core) = environment.command_plane().await; + let snapshot = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let caller = snapshot + .instances + .iter() + .find(|instance| instance.configuration.get("command_registration").is_some()) + .unwrap(); + let commands = runtime.prepare_command_line().await.unwrap(); + environment.store.start_command_line_writes().unwrap(); + let result = commands.execute(&caller.id, "run", &[]).await.unwrap(); + assert_eq!(result.stdout, "ok"); + commands.shutdown().await; + environment + .store + .shutdown_command_line_writes() + .await + .unwrap(); + environment + .assert_upstream_accounting(key_id, account.as_str()) + .await; + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn adapter_reconfiguration_disable_and_rollback_keep_inflight_generation_and_reap_workers() { + let original = MockServer::start().await; + let replacement = MockServer::start().await; + for (server, expected) in [(&original, 2), (&replacement, 1)] { + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(expected) + .mount(server) + .await; + } + let (cache, store, runtime) = setup(configuration(&original.uri(), false)).await; + let old = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let account = Account::new(); + let mut old_stream = execute(&old, account.clone(), "key-one", None); + old_stream.next().await.unwrap().unwrap(); + { + let mut snapshot = store.snapshot.lock().unwrap(); + snapshot.config_revision = gateway_admin::model::Revision::new(2).unwrap(); + snapshot.instances[0].revision = snapshot.config_revision; + snapshot.instances[0].configuration = configuration(&replacement.uri(), false); + } + let new = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + assert_ne!(old.id(), new.id()); + let mut new_stream = execute(&new, account.clone(), "key-one", None); + new_stream.next().await.unwrap().unwrap(); + drop(old); + let old_state = old_stream + .next() + .await + .unwrap() + .unwrap() + .take_session_update() + .unwrap(); + assert!(old_stream.next().await.is_none()); + drop(old_stream); + { + let mut snapshot = store.snapshot.lock().unwrap(); + snapshot.config_revision = gateway_admin::model::Revision::new(3).unwrap(); + snapshot.instances[0].revision = snapshot.config_revision; + snapshot.instances[0].enabled = false; + } + let disabled = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(3).unwrap()) + .await + .unwrap(); + assert!( + context(&disabled, "key-one") + .upstream_adapter( + &ProviderKind::new("openai").unwrap(), + &UpstreamModelId::new("native-model").unwrap() + ) + .unwrap() + .is_none() + ); + drop(new); + assert!( + new_stream + .next() + .await + .unwrap() + .unwrap() + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Completed(_))) + ); + assert!(new_stream.next().await.is_none()); + drop(new_stream); + drop(disabled); + super::wait_until_empty(cache.path()).await; + assert_eq!(Arc::strong_count(&account), 1); + { + let mut snapshot = store.snapshot.lock().unwrap(); + snapshot.config_revision = gateway_admin::model::Revision::new(4).unwrap(); + snapshot.instances[0].revision = snapshot.config_revision; + snapshot.instances[0].enabled = true; + snapshot.instances[0].configuration = configuration(&original.uri(), false); + } + let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(4).unwrap()) + .await + .unwrap(); + // 恢复同一配置仍是新代次,不能复活旧进程中的续接身份。 + let error = execute(&restored, account.clone(), "key-one", Some(old_state)) + .next() + .await + .unwrap() + .unwrap_err(); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + let mut fresh = execute(&restored, account.clone(), "key-one", None); + while let Some(event) = fresh.next().await { + event.unwrap(); + } + drop(fresh); + drop(restored); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; + assert_eq!(Arc::strong_count(&account), 1); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs index c15a1bbe9..ae1796f16 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -6,30 +6,51 @@ use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; use sha2::{Digest as _, Sha256}; #[test] -fn host_compatibility_covers_every_sdk_capability_and_permission() { +fn host_compatibility_only_advertises_known_contracts() { let compatibility: PluginHostCompatibility = serde_json::from_str(include_str!("../../plugin-host-compatibility.json")) .expect("compatibility JSON"); assert!(compatibility.is_valid()); - for capability in all_capabilities() { - assert_exhaustive_capability(capability); - let identifier = identifier(capability); - assert!( - compatibility.supports_capability(&identifier, 1), - "missing capability {identifier}" - ); + assert_eq!( + compatibility.manifest_schema_versions, + [gateway_plugin_sdk::MANIFEST_VERSION] + ); + assert_eq!( + compatibility.protocol_versions, + [gateway_plugin_sdk::PROTOCOL_VERSION] + ); + for entry in &compatibility.capabilities { + let capability: Capability = serde_json::from_value(entry.capability.clone().into()) + .expect("host capability is understood by the SDK"); + assert_eq!(entry.capability, capability.identifier()); + for version in &entry.versions { + assert!( + capability.contract_versions().contains(version), + "unknown contract {} v{version}", + entry.capability + ); + } } - for permission in Permission::ALL { - assert_exhaustive_permission(permission); - let identifier = identifier(permission); - assert!( - compatibility.supports_permission(&identifier), - "missing permission {identifier}" - ); + for identifier in &compatibility.permissions { + let permission: Permission = serde_json::from_value(identifier.clone().into()) + .expect("host permission is understood by the SDK"); + assert_eq!(identifier, permission.as_str()); } } +#[test] +fn host_compatibility_preserves_both_middleware_contracts() { + let compatibility: PluginHostCompatibility = + serde_json::from_str(include_str!("../../plugin-host-compatibility.json")) + .expect("compatibility JSON"); + assert!(compatibility.supports_capability("middleware", 1)); + assert!(compatibility.supports_capability("middleware", 2)); + assert!(!compatibility.supports_capability("middleware", 3)); + assert!(!compatibility.supports_capability("openai", 1)); + assert!(!compatibility.supports_permission("xai")); +} + #[tokio::test] async fn package_inspector_returns_static_requirements_without_starting_the_plugin() { let archive = crate::support::package_with_contributions( @@ -61,60 +82,3 @@ async fn package_inspector_returns_static_requirements_without_starting_the_plug assert_eq!(requirements.capabilities, vec![("middleware".into(), 1)]); assert!(requirements.permissions.is_empty()); } - -fn identifier(value: impl serde::Serialize) -> String { - serde_json::to_value(value) - .expect("serialize identifier") - .as_str() - .expect("string identifier") - .to_owned() -} - -fn assert_exhaustive_capability(capability: Capability) { - match capability { - Capability::FrontendAuthentication - | Capability::Scheduler - | Capability::ModelRouter - | Capability::ModelCatalog - | Capability::RetryPolicy - | Capability::Middleware - | Capability::RequestLifecycle - | Capability::WebSocketObserver - | Capability::Usage - | Capability::CommandLine - | Capability::Management - | Capability::Maintenance => {} - } -} - -fn assert_exhaustive_permission(permission: Permission) { - match permission { - Permission::Network - | Permission::Requests - | Permission::Models - | Permission::Accounts - | Permission::Data - | Permission::PublicEndpoints - | Permission::Groups - | Permission::Keys - | Permission::KeyBudgets - | Permission::QuotaObservations => {} - } -} - -fn all_capabilities() -> [Capability; 12] { - [ - Capability::FrontendAuthentication, - Capability::Scheduler, - Capability::ModelRouter, - Capability::ModelCatalog, - Capability::RetryPolicy, - Capability::Middleware, - Capability::RequestLifecycle, - Capability::WebSocketObserver, - Capability::Usage, - Capability::CommandLine, - Capability::Management, - Capability::Maintenance, - ] -} diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs index 59985e5ed..0f98f2487 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs @@ -147,6 +147,49 @@ async fn registration_and_configuration_reject_network_and_model_callbacks() { session.shutdown(Duration::from_secs(1)).await; } +#[tokio::test] +async fn upstream_connections_require_their_own_grant_and_exact_execution_stage() { + let methods = [ + "host.upstream.http.do", + "host.upstream.http.do_stream", + "host.upstream.http.stream_read", + "host.upstream.http.stream_close", + "host.upstream.websocket.open", + "host.upstream.websocket.send", + "host.upstream.websocket.read", + "host.upstream.websocket.close", + ]; + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session_with_permissions( + Arc::clone(&callbacks), + vec![Permission::UpstreamConnections], + ) + .await; + for method in methods { + assert!( + invoke_callback(&session, Stage::Upstream, method) + .await + .is_ok() + ); + for stage in [ + Stage::Request, + Stage::Attempt, + Stage::Management, + Stage::Observation, + Stage::Registration, + ] { + assert_permission_denied(invoke_callback(&session, stage, method).await); + } + } + session.shutdown(Duration::from_secs(1)).await; + let (_cache, session) = + session_with_permissions(callbacks, vec![Permission::Network, Permission::Accounts]).await; + for method in methods { + assert_permission_denied(invoke_callback(&session, Stage::Upstream, method).await); + } + session.shutdown(Duration::from_secs(1)).await; +} + #[tokio::test] async fn active_stages_use_resource_domains_without_operation_whitelists() { let callbacks = Arc::new(Callbacks::default()); diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index 0ba3a7ace..83f086a76 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -214,6 +214,16 @@ impl Environment { vec![], )]); } + let has_upstream_adapter = configuration.get("upstream_registration").is_some(); + if has_upstream_adapter { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::UpstreamAdapter, + vec![Stage::Upstream], + vec!["openai".into()], + vec!["openai".into()], + )]); + } let has_middleware = configuration.get("middleware_marker").is_some(); if has_middleware { contributes.extend([super::contribution_for_id( @@ -322,6 +332,17 @@ impl Environment { secrets: BTreeMap::new(), grants, bindings: [ + has_upstream_adapter.then(|| PluginCapabilityBinding { + contribution: format!("{plugin_id}.upstreamAdapter"), + stage: "upstream".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }), has_model_router.then(|| PluginCapabilityBinding { contribution: format!("{plugin_id}.modelRouter"), stage: "routing".into(), @@ -667,6 +688,16 @@ impl Environment { .unwrap() } + pub async fn assert_upstream_accounting(&self, key: &str, account: &str) { + let rows: Vec<(String, String, i32, i64, i64, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!( + "select outcome, provider_account_ref, attempt_count, input_tokens, output_tokens, cost_amount > 0 from {}.model_requests where client_api_key_ref=$1", self.schema, + ))).bind(key).fetch_all(&self.admin).await.unwrap(); + assert_eq!( + rows, + vec![("succeeded".into(), account.into(), 1, 7, 2, true)] + ); + } + pub async fn bound_model_requests(&self, client_key_id: &str) -> Vec<(String, String)> { sqlx::query_as(sqlx::AssertSqlSafe(format!( "select request_kind, subagent_kind from {}.model_requests diff --git a/backend/crates/gateway-plugin/runtime/tests/support/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs index aca7f2bad..917a370fc 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs @@ -45,6 +45,7 @@ pub fn contribution_for_id( Capability::ModelCatalog => "modelCatalog", Capability::RetryPolicy => "retryPolicy", Capability::Middleware => "middleware", + Capability::UpstreamAdapter => "upstreamAdapter", Capability::RequestLifecycle => "requestLifecycle", Capability::WebSocketObserver => "webSocketObserver", Capability::Usage => "usage", diff --git a/backend/crates/gateway-plugin/runtime/tests/support/native.rs b/backend/crates/gateway-plugin/runtime/tests/support/native.rs index 626618630..54ddbcbd9 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/native.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/native.rs @@ -73,12 +73,31 @@ impl Provider for NativeProvider { let model = candidate.upstream_model().cloned().ok_or_else(|| { ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) })?; + let adapter = context.upstream_adapter(candidate.provider(), &model)?; let metadata = ProviderCallMetadata::new( candidate.provider().clone(), model, account, - UpstreamTransport::new("http_sse").unwrap(), + UpstreamTransport::new( + adapter + .as_ref() + .map_or("http_sse", |adapter| adapter.transport()), + ) + .unwrap(), ); + if let Some(adapter) = adapter { + let account = Arc::new(FixtureConnection(metadata.provider_account_id().clone())); + let events = adapter.execute( + gateway_core::engine::upstream_adapter::UpstreamAdapterInvocation { + operation: request.operation().clone(), + headers: vec![], + context, + metadata: metadata.clone(), + account, + }, + ); + return Ok(ProviderStream::new(metadata, events, ())); + } let response = ResponseMeta::new("fixture-response", MODEL); let event = |name: &str, fact| { ProviderEvent::canonical_with_wire( @@ -108,6 +127,47 @@ impl Provider for NativeProvider { } } +// 仅测试 Core / Runtime / Store 组合;真实 Provider 的凭据解释由各自合同测试验证。 +struct FixtureConnection(ProviderAccountId); + +impl gateway_core::engine::upstream_adapter::UpstreamAccountConnection for FixtureConnection { + fn account_id(&self) -> &ProviderAccountId { + &self.0 + } + fn credential_revision(&self) -> gateway_core::account::CredentialRevision { + gateway_core::account::CredentialRevision::new(1).unwrap() + } + fn authentication_kind(&self) -> &str { + "oauth" + } + fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> { + None + } + fn authorization( + &self, + ) -> Result, ProviderError> { + Ok(vec![ + gateway_core::engine::middleware::MiddlewareHeader::new( + "authorization", + bytes::Bytes::from_static(b"Bearer fixture-native-token"), + ), + ]) + } + fn calculate_cost( + &self, + _: Option<&str>, + _: &gateway_core::metering::Usage, + ) -> Option { + gateway_core::metering::CalculatedCost::from_usd_ticks(123).ok() + } + fn record_failure( + &self, + error: ProviderError, + ) -> futures::future::BoxFuture<'_, ProviderError> { + Box::pin(async move { error }) + } +} + pub fn provider_registry() -> ProviderRegistry { ProviderRegistry::new([ Arc::new(NativeProvider("openai")) as Arc, diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs index 9991f2464..3497e6a14 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs @@ -18,6 +18,11 @@ use serde_json::{Value, json}; use tokio::io::AsyncWriteExt as _; use tokio::sync::{Mutex, Notify, mpsc, oneshot}; +use gateway_plugin_sdk::call::{ + model::{ExecutionEvent, WireEvent, WirePayload}, + upstream_adapter::{UpstreamAdapterEvent, UpstreamAdapterRequest}, +}; + type CallbackResult = Result<(Value, Vec), PluginFault>; struct PendingCallback { parent: u64, @@ -396,6 +401,23 @@ impl Peer { return; } match method.as_str() { + "upstream_adapter.register" => { + self.send( + Message::Result { + id, + result: json!({}), + }, + serde_json::to_vec(&self.configuration["upstream_registration"]).unwrap(), + ) + .await; + return; + } + "upstream_adapter.execute" => { + if let Err(error) = self.upstream_adapter(id, payload).await { + self.send(Message::Error { id, error }, vec![]).await; + } + return; + } "plugin.reconcile" => { let result = if let Some(results) = self.data_queries(id).await { self.append_observation_marker( @@ -1308,7 +1330,12 @@ async fn main() { if method == "hang_uncancellable" { uncancellable.insert(id); } - if method.starts_with("stream") || matches!(method.as_str(), "middleware.handle") { + if method.starts_with("stream") + || matches!( + method.as_str(), + "middleware.handle" | "upstream_adapter.execute" + ) + { peer.streams .lock() .await @@ -1385,3 +1412,101 @@ async fn main() { } } } + +impl Peer { + async fn upstream_adapter(&self, id: u64, payload: Vec) -> Result<(), PluginFault> { + let (request, body) = UpstreamAdapterRequest::decode(&payload).unwrap(); + // 假凭据用于检测宿主是否把已选账号令牌放入了插件输入。 + assert!(!String::from_utf8_lossy(&payload).contains("fixture-native-token")); + self.append_observation_marker("upstream_marker", &json!({"key":request.client_key_id,"account":request.account_id,"continuation":request.continuation})); + self.send( + Message::Result { + id, + result: json!({}), + }, + vec![], + ) + .await; + let mut response_body = vec![]; + for callback in self.configuration["upstream_callbacks"] + .as_array() + .into_iter() + .flatten() + { + let payload = if callback["body"] == "request" { + body.clone() + } else { + vec![] + }; + let (result, payload) = self + .callback_payload( + id, + callback["method"].as_str().unwrap(), + callback["params"].clone(), + payload, + ) + .await?; + if let Some(stream) = result["stream"].as_str() { + loop { + let (result, chunk) = self + .callback_payload( + id, + "host.upstream.http.stream_read", + json!({"stream":stream,"maximum_bytes":65536}), + vec![], + ) + .await?; + response_body.extend(chunk); + if result["eof"] == true { + break; + } + } + } else if !payload.is_empty() { + response_body = payload; + } + } + let events = self.configuration["upstream_events"].as_array().unwrap(); + let credits = self.streams.lock().await.get(&id).unwrap().clone(); + for (sequence, event) in events.iter().enumerate() { + let mut message = UpstreamAdapterEvent::new( + serde_json::from_value::(event["event"].clone()).unwrap(), + ); + message.service_tier = event["service_tier"].as_str().map(str::to_owned); + message.continuation = event + .get("continuation") + .map(|value| serde_json::from_value(value.clone()).unwrap()); + message.failure = event + .get("failure") + .map(|value| serde_json::from_value(value.clone()).unwrap()); + if event["wire"] == "http" { + message.event.wire = Some(WireEvent { + protocol: request.protocol.clone(), + payload: WirePayload::RawJson { + body: response_body.clone(), + }, + }); + } else if event["wire"] == "sse" { + message.event.wire = Some(WireEvent { + protocol: request.protocol.clone(), + payload: WirePayload::RawSse { + frame: response_body.clone(), + }, + }); + } + let payload = message.encode().unwrap(); + credits.take(payload.len() as u64).await; + self.send( + Message::Stream { + id, + sequence: sequence as u64, + }, + payload, + ) + .await; + } + let error = (self.configuration["upstream_tail_error"] == true) + .then(|| PluginFault::new(ErrorCode::Fault, "fixture terminal fault")); + self.send(Message::End { id, error }, vec![]).await; + Ok(()) + } +} diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index 47c4b21ae..54ff5ca19 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -25,6 +25,7 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声 | `call::frontend_authentication` | 数据面认证信封、认证器标识与外部 principal 结果 | | `call::model` | 宿主模型调用的 canonical 事实、原生 wire 与有界事件编解码 | | `call::middleware` | 洋葱中间件请求/响应 head、single-use next 与惰性正文 frame 合同 | +| `call::upstream_adapter` | 内置 OpenAI / xAI 的[受管上游适配器](docs/upstream-adapters.md)注册、执行、出站与续接合同 | | `call::policy` | 模型路由、账号调度、受约束重试及终态用量观察合同 | | `call::catalog` | 固定 Provider 的模型别名注册合同 | | `call::data` | 最小账号事实与已有额度观测的只读合同 | @@ -33,8 +34,9 @@ SDK 仍处于实验阶段;能力是否可用取决于宿主支持、清单声 | `call::management` | 管理 API、页面与资源声明、公开回调,以及 CLI 命令与待保存账号 | | `client` | 通过 `io` feature 开启的 `PluginBuilder`、会话、中间件与异步帧收发 | -`call/` 按业务能力组织数据,`message.rs` 定义消息与帧。请求/响应改写、协议转换和思考参数映射统一使用 -`call::middleware`;WebSocket 观察接口不能改写连接 +`call/` 按业务能力组织数据,`message.rs` 定义消息与帧。已有请求链的请求/响应改写、协议转换和思考参数映射使用 +`call::middleware`;需要指定业务上游并解析结果时使用 `call::upstream_adapter`,作为洋葱链终端复用宿主账号与结算。 +WebSocket 观察接口不能改写连接 插件使用的导入路径例如: diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index ec61d603b..5824a36b7 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -9,7 +9,7 @@ | --- | --- | | 组合处理器并声明权限 | [作者入口](#类型化作者入口) · [能力与方法](#能力与方法) · [访问域](#访问域) | | 使用宿主资源 | [基础事实](#基础事实) · [额度刷新](#额度观测刷新) · [账号](#账号与凭据) · [自有资源](#自有资源与维护) · [Key 预算](#client-key-预算) · [模型](#key模型与模型调用) · [网络](#网络) | -| 扩展请求链 | [中间件](#洋葱中间件) · [模型目录](#模型目录) · [重试](#重试决策) · [路由、调度与观察](#路由调度与观察) · [入口认证](#数据面入口认证) | +| 扩展请求链 | [中间件](#洋葱中间件) · [上游适配器](upstream-adapters.md) · [模型目录](#模型目录) · [重试](#重试决策) · [路由、调度与观察](#路由调度与观察) · [入口认证](#数据面入口认证) | | 管理与交互 | [状态、日志与迁移](#状态日志与迁移) · [管理页面](#管理页面公开入口与-cli) · [页面宿主桥](#页面宿主桥-v2) · [命令行](#命令行) | ## 类型化作者入口 @@ -52,6 +52,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | 能力 | 类型化方法 | | --- | --- | | `middleware` | `PluginBuilder::middleware` / `middleware.handle` | +| `upstream_adapter` | `methods::UPSTREAM_ADAPTER_REGISTER`、`methods::UPSTREAM_ADAPTER_EXECUTE`,见[受管上游](upstream-adapters.md) | | `model_router` | `methods::ROUTE_MODEL` | | `model_catalog` | `PluginBuilder::model_catalog` / `methods::MODEL_CATALOG_REGISTER` | | `retry_policy` | `methods::RETRY_DECISION` | @@ -71,6 +72,7 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。插件提供以下扩展能 | 权限 | 开放的宿主能力 | | --- | --- | | `network` | 通过 `host.http.*` 使用宿主受管出站网络;仍受统一代理、超时、大小和流控规则约束 | +| `upstream_connections` | 仅在 `upstream` 阶段使用已选账号的受管 HTTP / WebSocket;宿主注入凭据和账号代理,不能读取原始凭据或重新选号 | | `models` | 列出非秘密 Key、按所选 Key 查询模型,以及通过 `host.model.*` 调用模型;调用可能产生消耗 | | `accounts` | 查询账号、读取原始凭据及创建或替换账号;写入仍经过 revision CAS、审计和发布事务 | | `data` | 在管理/命令/维护阶段只读账号、Key 的最小基础信息及已有额度观测,不包含凭据、写入或预测 | @@ -244,6 +246,10 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限 ### 网络 +`HostClient::http(request, body)` 返回响应头与 `HostHttpBody`。正文用 `read()` 按需读取, +用 `collect(maximum_bytes)` 有界收集,提前结束用 `close()`;EOF 与重复关闭不再调用宿主。 +父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。账号上游使用同一正文对象,权限与目标见[受管上游](upstream-adapters.md) + `network` 域通过 `host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。URL、header 和正文可能含敏感数据, 正文使用独立二进制载荷。宿主统一施加代理、超时、帧与流限制;插件不能绕过宿主端口取得额外调用身份。 地址被拒绝返回 `permission_denied`,期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。 @@ -291,6 +297,21 @@ restore_and_validate_response(response).await 仅实现中间件时也可使用轻量的 `MiddlewarePlugin`;需要与管理或其他方法组合时使用 `PluginBuilder`,两者复用相同的 `MiddlewareCall` 和 `MiddlewareResponse` +### 扩展的组合边界 + +请求处理沿 `request middleware → 原生选路与选号 → attempt middleware → 原生上游或 upstream_adapter` 推进, +响应按相反顺序返回;适配器是现有洋葱链的终端,不能再次调用 `next` 或组织换号重试 + +| 工作 | 组合方式 | +| --- | --- | +| 请求头/正文改写、提前拒绝、响应头处理 | `middleware` 包裹一次 `next.run(request)` | +| 响应逐帧转换、观察 | 返回惰性 `map_frames` / `inspect_frames`,不能在 `next` 返回时就把流视为结束 | +| 新上游路径、协议编码、错误及用量解析 | `upstream_adapter` 终端,复用已选账号和原生结算 | +| 目标限制、凭据注入、网络发送状态 | 宿主网络外层,普通 HTTP 与账号 HTTP 共用发送和正文资源实现 | +| 路由、调度、重试决策 | 各自的决策端口,Core 保持最终裁决和预算 | +| RPC Credit、取消、流关闭、WebSocket 续接 | 会话及资源生命周期,不作为可重排的中间件 | +| 终态观察、管理与维护 | 原有观察/管理/维护入口,不嵌入请求 `next` 链 | + ### 模型目录 `model_catalog` v1 在 registration 阶段一次提交 `ModelCatalogRegistration { models: Vec }`,不需要请求级 binding。 diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index d8abaf0f4..16634cbcc 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -22,6 +22,18 @@ `package_for(host, os, architecture)` 检查版本与平台。运行模式仅为 `trustedProcess`:插件拥有与宿主 相同的系统身份,**不是进程沙箱** +宿主实际开放的合同由 [plugin-host-compatibility.json](../../runtime/plugin-host-compatibility.json) +声明,SDK 的 `Capability::contract_versions()` 只表示 SDK 能描述的行为版本,不能代替宿主支持检查 + +| 版本字段 | 对应合同 | +| --- | --- | +| `manifestVersion` / `manifest_schema_versions` | 插件清单格式 | +| `package.protocolVersion` / `protocol_versions` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 | +| `contributes..version` / `capabilities[].versions` | 指定扩展能力的行为合同 | +| 宿主声明的 `schema_version` | 宿主兼容声明自身的格式 | + +`capabilities` 使用扩展能力标识,`permissions` 使用资源访问域标识;Provider、模型和账号 ID 不属于这两个集合 + ## 扩展项简写 普通作者声明可以省略 `id`、`version` 和固定阶段: diff --git a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md new file mode 100644 index 000000000..8beda0a0e --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md @@ -0,0 +1,86 @@ +# 受管上游适配器 + +`upstream_adapter` v1 是现有生成请求洋葱链的终端,只挂到内置 `openai` / `xai`,不替换独立图像或压缩端点。 +插件定义 Base URL、业务路径、请求编码和响应解析;宿主继续持有身份、ACL、选号、租约、凭据、代理、重试和账本。 +普通请求或响应加工使用 [middleware](capabilities.md#洋葱中间件) + +## 声明与绑定 + +清单声明 `upstream_adapter`、版本 `1`、阶段 `upstream`,以及匹配的 `input_formats` / `output_formats`; +需要 `requests` 与 `upstream_connections`。使用 `PluginBuilder::on` 注册 +`methods::UPSTREAM_ADAPTER_REGISTER` 与 `methods::UPSTREAM_ADAPTER_EXECUTE` + +注册返回 `UpstreamAdapterRegistration`,包含 1 至 16 个适配器: + +| 字段 | 含义 | +| --- | --- | +| `id` | 实例内唯一标识,不是 Provider 或账号类型 | +| `provider` | `openai` 或 `xai` | +| `base_url` | 以 `/` 结尾的 HTTP(S) 基址,无用户信息、查询串或 fragment | +| `paths` | 不以 `/` 开头的基址相对路径,含 `inference` 或 `auxiliary` 用途,无通配符 | +| `authentication_kinds` | 可使用的现有认证类型,宿主复核已选账号 | +| `transport` | `http_json`、`http_sse` 或 `websocket` | +| `protocol` | 请求和客户端 wire 使用的协议,须在清单输入、输出格式内 | +| `models` | 公开请求模型的精确匹配,空集合沿用绑定范围 | + +实例绑定沿用 Key、账号组、Provider、模型范围,只允许一次 `upstream` 绑定和 `reject` 故障策略。 +同一请求不能同时命中两个适配器;配置准备时拒绝重叠,执行时再次复核。适配器未命中时仍使用原生上游, +已命中但不可用时拒绝请求。安装、授权、配置、启停和升级使用已有插件管理 + +能力版本、清单版本、进程 RPC 版本和宿主兼容声明格式版本分别判断,见[清单](manifest.md)。 +Provider、业务协议和传输名称不能写入宿主兼容清单的 capability / permission / RPC 版本位置 + +## 执行与受管出站 + +`TypedCall` 包含只读的 Key、账号、凭据版本、模型、协议、Header 和宿主续接投影; +原始请求正文位于 `call.payload`,原始账号凭据不进入插件输入。宿主首次消费冷流才调用插件 + +`disable_fast` 是宿主冻结的请求策略。OpenAI 请求正文在进入适配器前复用原生 Fast 策略处理; +适配器转换为其他上游协议时也须遵守该字段,不能重新启用 Fast / priority 档位 + +HTTP 请求使用 `call.host.upstream_http(request, body).await?`,返回 `HostHttpResponse`: + +```rust,ignore +let response = call.host.upstream_http(UpstreamHttpRequest { + method: "POST".into(), path: "responses".into(), query: vec![], headers: vec![], +}, encode_request(&call.payload)?).await?; +let status = response.status; +let mut body = response.body; +while let Some(bytes) = body.read().await? { + // 分段不等于 SSE 事件;插件按自己的上游协议增量解码。 + decode_upstream(status, bytes)?; +} +``` + +正文与普通 `HostClient::http` 共用 `read`、`collect(maximum_bytes)` 和 `close`;每次读取至多 64 KiB, +不后台预读,不向作者暴露流 ID。提前关闭及收集超限会释放正文,父调用结束或取消时宿主兜底回收。 +JSON 可有界收集,SSE 应增量解析,不能把读取块当成完整事件 + +WebSocket 使用 `call.host.upstream_websocket(request).await?`,返回 +`UpstreamWebSocketUpgrade::Connected { headers, connection }` 或 `Rejected { status, headers, body }`。 +连接提供 `send(kind, body)`、`read()`、`close()`,支持完整文本与二进制消息,Ping/Pong 由宿主处理。 +同一连接一次只执行一个操作,握手拒绝保留 HTTP 状态和正文。底层 `host.upstream.*` 是 SDK 使用的资源协议, +不是额外的业务中间件链 + +宿主只对声明的基址和精确路径注入已选账号认证,禁止覆盖 Host、认证、Cookie 与握手保护头; +重定向不能携带凭据逃逸。出站复用该账号的代理,代理失败不能回退直连。 +`auxiliary` 路径的出站计入请求副作用水位,防止附件上传等操作在失败后被透明重复执行 + +## 事件、结算和续接 + +执行返回 `TypedReply` 的有界 `ResponseStream`。每个 chunk 使用 `UpstreamAdapterEvent::encode()`, +包含标准事实与可选原始 wire;事件需符合开始、内容、工具、用量和唯一结束的顺序。 +原始 JSON / SSE 字节与标准事实分开传递,错误使用 `UpstreamFailure`;宿主网络观测决定发送状态,插件不能将其降为未发送 + +用量提交标准 `Usage`,费用由内置 Provider 按已冻结的上游模型与价格计算,响应回显模型不切换计价。 +`service_tier` 记录上游回显的最新档位,允许上游在完成时确定实际值;OpenAI 计价沿用请求策略,回显档位仅作观测。 +未知用量或价格保留未知。宿主确认 RPC 正常结束后才发布完成事实,插件不能独立结算或组织换号重试 + +成功终态可附带 `UpstreamContinuation`,包含上游响应 ID、不超过 32 KiB 的私有续接材料和作用域: + +- `persisted`:续接材料随宿主会话保存,仍受实例、代次、Key、账号和凭据版本限制 +- `connection_local`:还必须保有本次已建立的 WebSocket;宿主在成功终态后保存同一连接,续接时独占取回 + +连接内状态不能跨账号、Key、凭据版本、插件进程或代次复用,失效时拒绝续接,不重新建立连接假装原生续接成功。 +每个适配器代次最多保留 64 条闲置连接,闲置期限 30 分钟;保留连接不占用上一轮账号租约。 +显式 `close()` 放弃连接内续接。普通成功、错误、取消与退出释放未保留连接 diff --git a/backend/crates/gateway-plugin/sdk/src/call/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/mod.rs index 4b6f22b6d..9322d483f 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/mod.rs @@ -12,3 +12,4 @@ pub mod observation; pub mod policy; pub mod registration; pub mod resources; +pub mod upstream_adapter; diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs b/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs index 612d11a0c..e47d9b694 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs @@ -20,7 +20,7 @@ pub(super) fn decode_event(bytes: &[u8]) -> Result( +pub(in crate::call) fn pack( prefix: [u8; 4], metadata: &T, payloads: [Vec; N], @@ -55,7 +55,7 @@ fn pack( Ok(result) } -fn unpack( +pub(in crate::call) fn unpack( prefix: [u8; 4], bytes: &[u8], ) -> Result<(T, [&[u8]; N]), ExecutionEncodingError> { diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs index 6788e310e..90461f659 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -mod codec; +pub(in crate::call) mod codec; pub use codec::{ExecutionEncodingError, MAX_EXECUTION_PAYLOAD_BYTES}; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] diff --git a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs new file mode 100644 index 000000000..add9502ea --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +use super::{UpstreamAdapterEvent, UpstreamAdapterRequest, UpstreamContinuation, UpstreamFailure}; +use crate::call::model::{ + ExecutionEncodingError, ExecutionEvent, + codec::{pack, unpack}, +}; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Metadata { + service_tier: Option, + continuation: Option, + failure: Option, +} + +pub(super) fn encode(value: UpstreamAdapterEvent) -> Result, ExecutionEncodingError> { + let metadata = Metadata { + service_tier: value.service_tier, + continuation: value.continuation, + failure: value.failure, + }; + pack(*b"GPA1", &metadata, [value.event.encode()?]) +} + +pub(super) fn decode(bytes: &[u8]) -> Result { + let (metadata, [event]): (Metadata, _) = unpack(*b"GPA1", bytes)?; + Ok(UpstreamAdapterEvent { + event: ExecutionEvent::decode(event)?, + service_tier: metadata.service_tier, + continuation: metadata.continuation, + failure: metadata.failure, + }) +} + +pub(super) fn encode_request( + request: UpstreamAdapterRequest, + body: Vec, +) -> Result, ExecutionEncodingError> { + pack(*b"GPAQ", &request, [body]) +} + +pub(super) fn decode_request( + bytes: &[u8], +) -> Result<(UpstreamAdapterRequest, Vec), ExecutionEncodingError> { + let (request, [body]) = unpack(*b"GPAQ", bytes)?; + Ok((request, body.to_vec())) +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs new file mode 100644 index 000000000..f0cf687d3 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs @@ -0,0 +1,227 @@ +//! 内置 OpenAI / xAI 账号上的上游转换合同,凭据不进入此线协议。 + +use serde::{Deserialize, Serialize}; + +use super::model::ExecutionEvent; + +mod codec; + +pub const REGISTER_METHOD: &str = "upstream_adapter.register"; +pub const EXECUTE_METHOD: &str = "upstream_adapter.execute"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BuiltinProvider { + OpenAi, + Xai, +} + +impl BuiltinProvider { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAi => "openai", + Self::Xai => "xai", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpstreamTransport { + HttpJson, + HttpSse, + #[serde(rename = "websocket")] + WebSocket, +} + +impl UpstreamTransport { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::HttpJson => "http_json", + Self::HttpSse => "http_sse", + Self::WebSocket => "websocket", + } + } +} + +/// 注册结果随实例配置冻结;同一实例可为两个内置 Provider 分别声明适配器。 +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamAdapterRegistration { + pub adapters: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamAdapterDeclaration { + pub id: String, + pub provider: BuiltinProvider, + pub base_url: String, + /// 相对 Base URL 的精确业务路径,不接受绝对 URL、查询串或通配符。 + pub paths: Vec, + pub authentication_kinds: Vec, + pub transport: UpstreamTransport, + /// 客户端请求及适配完成后的 wire 协议;标准事实独立于 wire 传递。 + pub protocol: String, + /// 公开请求模型的匹配范围;空集合沿用功能绑定范围。 + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpstreamPathPurpose { + Inference, + Auxiliary, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamPath { + pub path: String, + pub purpose: UpstreamPathPurpose, +} + +/// 元数据为宿主只读投影;请求正文独立置于调用二进制载荷。 +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamAdapterRequest { + pub adapter_id: String, + pub provider: BuiltinProvider, + pub upstream_model: String, + pub client_key_id: String, + pub account_id: String, + pub credential_revision: u64, + pub protocol: String, + pub client_transport: String, + /// 宿主冻结的强制策略;自定义上游编码同样不得请求 Fast / priority 档位。 + pub disable_fast: bool, + pub headers: Vec<(String, Vec)>, + /// 只能来自同账号、同插件代次的宿主续接记录,不从客户端正文信任此值。 + pub continuation: Option, +} + +impl UpstreamAdapterRequest { + /// 请求身份、业务头与私有续接材料同原始正文一起放入二进制载荷。 + pub fn encode(self, body: Vec) -> Result, super::model::ExecutionEncodingError> { + codec::encode_request(self, body) + } + + pub fn decode(bytes: &[u8]) -> Result<(Self, Vec), super::model::ExecutionEncodingError> { + codec::decode_request(bytes) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContinuationScope { + Persisted, + ConnectionLocal, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamContinuation { + pub scope: ContinuationScope, + pub upstream_response_id: String, + /// 适配器私有的续接材料,不包含账号凭据。 + pub state: serde_json::Map, +} + +/// 一个完整 GPE1 事件与可选的结算/续接事实;见 [`Self::encode`]。 +pub struct UpstreamAdapterEvent { + pub event: ExecutionEvent, + /// 上游回显的最新服务档位,可在终态确定;是否参与计价由内置 Provider 决定。 + pub service_tier: Option, + pub continuation: Option, + pub failure: Option, +} + +impl UpstreamAdapterEvent { + #[must_use] + pub const fn new(event: ExecutionEvent) -> Self { + Self { + event, + service_tier: None, + continuation: None, + failure: None, + } + } + + /// 编码有界事件,wire 原字节仍使用 GPE1 二进制段。 + pub fn encode(self) -> Result, super::model::ExecutionEncodingError> { + codec::encode(self) + } + + /// 校验版本、长度及元数据,再解码事件载荷。 + pub fn decode(bytes: &[u8]) -> Result { + codec::decode(bytes) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UpstreamFailureKind { + InvalidRequest, + Unsupported, + Unauthorized, + PermissionDenied, + RateLimited, + QuotaExhausted, + Timeout, + Unavailable, + Protocol, +} + +/// 解析后的上游拒绝事实;发送状态始终由宿主网络观测,不接受插件声明。 +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamFailure { + pub kind: UpstreamFailureKind, + pub status: Option, + pub retry_after_ms: Option, + pub message: String, + pub code: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamHttpRequest { + pub method: String, + pub path: String, + pub headers: Vec<(String, String)>, + #[serde(default)] + pub query: Vec<(String, String)>, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamWebSocketRequest { + pub path: String, + pub headers: Vec<(String, String)>, + #[serde(default)] + pub query: Vec<(String, String)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebSocketMessageKind { + Text, + Binary, +} + +/// 消息数据独立放入二进制载荷,宿主处理 Ping/Pong 与关闭握手。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamWebSocketMessage { + pub kind: WebSocketMessageKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamWebSocketRead { + pub kind: Option, + pub eof: bool, +} diff --git a/backend/crates/gateway-plugin/sdk/src/capability.rs b/backend/crates/gateway-plugin/sdk/src/capability.rs index bd46f070a..8550751d1 100644 --- a/backend/crates/gateway-plugin/sdk/src/capability.rs +++ b/backend/crates/gateway-plugin/sdk/src/capability.rs @@ -12,6 +12,7 @@ pub enum Capability { ModelCatalog, RetryPolicy, Middleware, + UpstreamAdapter, RequestLifecycle, WebSocketObserver, Usage, @@ -21,6 +22,26 @@ pub enum Capability { } impl Capability { + /// SDK 能描述的行为合同版本;具体宿主可以只开放其中一部分。 + #[must_use] + pub const fn contract_versions(self) -> &'static [u32] { + match self { + Self::Middleware => &[1, 2], + Self::FrontendAuthentication + | Self::Scheduler + | Self::ModelRouter + | Self::ModelCatalog + | Self::RetryPolicy + | Self::RequestLifecycle + | Self::WebSocketObserver + | Self::Usage + | Self::CommandLine + | Self::Management + | Self::Maintenance => &[1], + Self::UpstreamAdapter => &[1], + } + } + /// 稳定能力标识;默认扩展项 ID 由它派生,不受显示名称影响。 #[must_use] pub const fn identifier(self) -> &'static str { @@ -31,6 +52,7 @@ impl Capability { Self::ModelCatalog => "model_catalog", Self::RetryPolicy => "retry_policy", Self::Middleware => "middleware", + Self::UpstreamAdapter => "upstream_adapter", Self::RequestLifecycle => "request_lifecycle", Self::WebSocketObserver => "web_socket_observer", Self::Usage => "usage", @@ -50,6 +72,7 @@ impl Capability { Self::ModelCatalog => &[Stage::Registration], Self::RetryPolicy => &[Stage::Retry], Self::Middleware => &[], + Self::UpstreamAdapter => &[Stage::Upstream], Self::RequestLifecycle | Self::WebSocketObserver | Self::Usage => &[Stage::Observation], Self::CommandLine => &[Stage::CommandLine], Self::Management => &[Stage::Management], @@ -70,6 +93,8 @@ pub enum Stage { Retry, Request, Attempt, + /// Core 登记已选账号的 attempt 后才启动的受管上游执行。 + Upstream, Observation, Management, CommandLine, @@ -153,11 +178,12 @@ pub enum Permission { Keys, KeyBudgets, QuotaObservations, + UpstreamConnections, } impl Permission { /// 当前公开访问域,安装摘要与授权校验复用同一集合。 - pub const ALL: [Self; 10] = [ + pub const ALL: [Self; 11] = [ Self::Network, Self::Models, Self::Accounts, @@ -168,6 +194,7 @@ impl Permission { Self::Keys, Self::KeyBudgets, Self::QuotaObservations, + Self::UpstreamConnections, ]; #[must_use] @@ -183,6 +210,7 @@ impl Permission { Self::Keys => "keys", Self::KeyBudgets => "key_budgets", Self::QuotaObservations => "quota_observations", + Self::UpstreamConnections => "upstream_connections", } } @@ -200,6 +228,7 @@ impl Permission { Self::Keys => "专用 API Key", Self::KeyBudgets => "API Key 预算", Self::QuotaObservations => "账号额度观测", + Self::UpstreamConnections => "账号上游连接", } } @@ -208,6 +237,9 @@ impl Permission { pub const fn description(self) -> &'static str { match self { Self::Network => "访问网络", + Self::UpstreamConnections => { + "使用当前请求所选账号的身份与代理连接插件声明的上游,不提供原始凭据读取或账号修改" + } Self::Models => "查询模型与 API Key 信息并调用模型,可能产生消耗", Self::Accounts => "读取和修改账号,包括访问原始凭据", Self::Data => { diff --git a/backend/crates/gateway-plugin/sdk/src/client/http.rs b/backend/crates/gateway-plugin/sdk/src/client/http.rs new file mode 100644 index 000000000..6f8b95662 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/http.rs @@ -0,0 +1,204 @@ +//! HTTP 正文句柄封装;普通出站和已选账号出站共用惰性读取与提前关闭语义。 + +use serde::{Serialize, de::DeserializeOwned}; + +use super::{HostClient, SessionError}; +use crate::{ + ErrorCode, PluginFault, + call::{host as wire, upstream_adapter::UpstreamHttpRequest}, +}; + +/// 响应头与惰性正文;不包含宿主的内部流句柄。 +pub struct HostHttpResponse { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: HostHttpBody, +} + +/// 父调用内的单消费者正文;不后台预读,父调用结束或取消时宿主兜底回收。 +pub struct HostHttpBody { + host: HostClient, + stream: Option, + upstream: bool, +} + +impl HostClient { + /// 使用普通网络授权发送请求;账号上游使用 [`Self::upstream_http`]。 + /// + /// # Errors + /// 权限、期限、请求或网络无效时失败。 + pub async fn http( + &self, + request: wire::HttpRequest, + body: Vec, + ) -> Result { + self.open_http("host.http.do_stream", request, body, false) + .await + } + + /// 由宿主附加已选账号的认证与代理,返回统一的惰性正文。 + /// + /// # Errors + /// 路径、权限、期限、网络或宿主响应无效时失败。 + pub async fn upstream_http( + &self, + request: UpstreamHttpRequest, + body: Vec, + ) -> Result { + self.open_http("host.upstream.http.do_stream", request, body, true) + .await + } + + async fn open_http( + &self, + method: &str, + request: T, + body: Vec, + upstream: bool, + ) -> Result { + let (response, payload): (wire::HttpResponse, _) = + callback(self, method, request, body).await?; + if !payload.is_empty() || response.stream.as_ref().is_none_or(String::is_empty) { + return Err(invalid()); + } + Ok(HostHttpResponse { + status: response.status, + headers: response.headers, + body: HostHttpBody { + host: self.clone(), + stream: response.stream, + upstream, + }, + }) + } +} + +impl HostHttpBody { + /// 按需读取至多 64 KiB,EOF 后再次读取不再调用宿主。 + /// + /// # Errors + /// 父调用结束、流无效或网络失败时返回错误。 + pub async fn read(&mut self) -> Result>, PluginFault> { + let Some(stream) = &self.stream else { + return Ok(None); + }; + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Read { + eof: bool, + } + let maximum_bytes = u32::try_from(self.host.maximum_stream_chunk_bytes().min(64 * 1024)) + .map_err(|_| invalid())?; + let (result, payload): (Read, _) = callback( + &self.host, + if self.upstream { + "host.upstream.http.stream_read" + } else { + "host.http.stream_read" + }, + wire::StreamRead { + stream: stream.clone(), + maximum_bytes, + }, + vec![], + ) + .await?; + if payload.len() > maximum_bytes as usize || (result.eof && !payload.is_empty()) { + let _ = self.close().await; + return Err(invalid()); + } + if result.eof { + self.stream = None; + Ok(None) + } else { + Ok(Some(payload)) + } + } + + /// 收集有限正文;超出调用方预算或读取失败时提前关闭,不把流无限缓冲到内存。 + /// + /// # Errors + /// 读取失败或超出 `maximum_bytes` 时返回错误。 + pub async fn collect(mut self, maximum_bytes: usize) -> Result, PluginFault> { + let mut body = Vec::new(); + loop { + match self.read().await { + Ok(Some(chunk)) if chunk.len() <= maximum_bytes.saturating_sub(body.len()) => { + body.extend(chunk) + } + Ok(None) => return Ok(body), + result => { + let _ = self.close().await; + return Err(result.err().unwrap_or_else(|| { + PluginFault::new( + ErrorCode::Capacity, + "HTTP body exceeds the collection limit", + ) + })); + } + } + } + } + + /// 提前释放正文,重复关闭或 EOF 后关闭不再调用宿主。 + /// + /// # Errors + /// 父调用已结束或宿主拒绝释放时失败。 + pub async fn close(&mut self) -> Result<(), PluginFault> { + let Some(stream) = &self.stream else { + return Ok(()); + }; + empty_callback( + &self.host, + if self.upstream { + "host.upstream.http.stream_close" + } else { + "host.http.stream_close" + }, + wire::StreamClose { + stream: stream.clone(), + }, + vec![], + ) + .await?; + self.stream = None; + Ok(()) + } +} + +pub(super) async fn callback( + host: &HostClient, + method: &str, + request: T, + body: Vec, +) -> Result<(R, Vec), PluginFault> { + let reply = host + .call( + method, + serde_json::to_value(request).map_err(|_| invalid())?, + body, + ) + .await + .map_err(SessionError::into_plugin_fault)?; + Ok(( + serde_json::from_value(reply.result).map_err(|_| invalid())?, + reply.payload, + )) +} + +pub(super) async fn empty_callback( + host: &HostClient, + method: &str, + request: T, + body: Vec, +) -> Result<(), PluginFault> { + let (metadata, payload): (serde_json::Value, _) = callback(host, method, request, body).await?; + if metadata != serde_json::json!({}) || !payload.is_empty() { + return Err(invalid()); + } + Ok(()) +} + +pub(super) fn invalid() -> PluginFault { + PluginFault::new(ErrorCode::InvalidInput, "invalid network callback payload") +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/mod.rs index 737016ed9..289f7ad9f 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/mod.rs @@ -2,16 +2,19 @@ mod data; mod frame; +mod http; mod keys; mod middleware; mod plugin; mod resources; mod session; +mod upstream_adapter; use crate::{ErrorCode, PluginFault}; use serde::{Serialize, de::DeserializeOwned}; pub use frame::{read_frame, validate_frame, write_frame}; +pub use http::{HostHttpBody, HostHttpResponse}; pub use middleware::{ MiddlewareBody, MiddlewareBodySender, MiddlewareCall, MiddlewareNext, MiddlewarePlugin, MiddlewareRequest, MiddlewareResponse, @@ -23,6 +26,7 @@ pub use session::{ CallCancellation, CallFuture, CallReply, HostClient, HostReply, PluginCall, PluginHandler, PluginSession, ResponseStream, SessionConfig, SessionError, StreamSender, }; +pub use upstream_adapter::{UpstreamWebSocket, UpstreamWebSocketUpgrade}; async fn payload_call( host: &HostClient, 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 ced914673..3ac64a382 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/methods.rs @@ -2,9 +2,30 @@ use crate::{ Capability as C, Stage as S, - call::{catalog, frontend_authentication as frontend, host, management, observation, policy}, + call::{ + catalog, frontend_authentication as frontend, host, management, observation, policy, + upstream_adapter, + }, }; +pub const UPSTREAM_ADAPTER_REGISTER: Method = + Method::new( + upstream_adapter::REGISTER_METHOD, + &[C::UpstreamAdapter], + &[S::Registration], + decode_metadata_without_payload, + encode_payload, + ); + +pub const UPSTREAM_ADAPTER_EXECUTE: Method = + Method::new( + upstream_adapter::EXECUTE_METHOD, + &[C::UpstreamAdapter], + &[S::Upstream], + super::typed::decode_upstream, + super::typed::encode_stream, + ); + use super::{ Empty, Method, typed::{ diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs index e2edcf4c8..277bfa371 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs @@ -126,6 +126,21 @@ pub(super) fn decode_payload( Ok(typed_call(call, request, false)) } +pub(super) fn decode_upstream( + mut call: PluginCall, + stages: &'static [Stage], +) -> Result, PluginFault> { + validate_stage(&call, stages)?; + if !empty_object(&call.params) { + return Err(invalid_input()); + } + let (request, body) = + crate::call::upstream_adapter::UpstreamAdapterRequest::decode(&call.payload) + .map_err(|_| invalid_input())?; + call.payload = body; + Ok(typed_call(call, request, true)) +} + pub(super) fn encode_metadata( reply: TypedReply, ) -> Result { @@ -141,6 +156,22 @@ pub(super) fn encode_metadata( Ok(CallReply::unary(result, Vec::new())) } +pub(super) fn encode_stream(reply: TypedReply) -> Result { + let TypedReply { + result, + payload, + stream, + } = reply; + if !payload.is_empty() { + return Err(invalid_input()); + } + Ok(CallReply::stream( + serde_json::to_value(result).map_err(|_| invalid_input())?, + payload, + stream.ok_or_else(invalid_input)?, + )) +} + pub(super) fn encode_metadata_with_payload( reply: TypedReply, ) -> Result { 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 e63d8bcb5..de5e9cbfc 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,10 @@ pub(super) fn validate( match capability { C::Maintenance => required(methods::RECONCILE.name)?, C::Middleware => required(crate::call::middleware::HANDLE_METHOD)?, + C::UpstreamAdapter => { + required(methods::UPSTREAM_ADAPTER_REGISTER.name)?; + required(methods::UPSTREAM_ADAPTER_EXECUTE.name)?; + } C::ModelRouter => required(methods::ROUTE_MODEL.name)?, C::ModelCatalog => required(methods::MODEL_CATALOG_REGISTER.name)?, C::RetryPolicy => required(methods::RETRY_DECISION.name)?, diff --git a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs new file mode 100644 index 000000000..be4713e10 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs @@ -0,0 +1,140 @@ +//! 已选账号的受管 WebSocket;连接归属与跨轮续接由宿主管理。 + +use super::{ + HostClient, + http::{callback, empty_callback, invalid}, +}; +use crate::{ + ErrorCode, PluginFault, + call::{ + host::HttpResponse, + upstream_adapter::{ + UpstreamWebSocketMessage, UpstreamWebSocketRead, UpstreamWebSocketRequest, + WebSocketMessageKind, + }, + }, +}; + +/// 握手拒绝仍保留 HTTP 状态及正文,供适配器解析上游错误。 +pub enum UpstreamWebSocketUpgrade { + Connected { + headers: Vec<(String, String)>, + connection: UpstreamWebSocket, + }, + Rejected { + status: u16, + headers: Vec<(String, String)>, + body: Vec, + }, +} + +/// 本次调用的单消费者连接入口;不暴露凭据或可跨账号复用的连接句柄。 +/// +/// 结束调用时宿主关闭连接;只有成功终态携带连接内续接状态时才保留连接。 +/// 因此丢弃此对象不会提前关闭宿主尚待确认的续接连接。 +pub struct UpstreamWebSocket { + host: HostClient, + closed: bool, +} + +impl HostClient { + /// 建立连接,续接时复用宿主恢复的同一连接。 + /// + /// # Errors + /// 目标、权限、握手或续接归属无效时失败。 + pub async fn upstream_websocket( + &self, + request: UpstreamWebSocketRequest, + ) -> Result { + let (response, body): (HttpResponse, _) = + callback(self, "host.upstream.websocket.open", request, vec![]).await?; + if response.stream.is_some() || (response.status == 101 && !body.is_empty()) { + return Err(invalid()); + } + Ok(if response.status == 101 { + UpstreamWebSocketUpgrade::Connected { + headers: response.headers, + connection: UpstreamWebSocket { + host: self.clone(), + closed: false, + }, + } + } else { + UpstreamWebSocketUpgrade::Rejected { + status: response.status, + headers: response.headers, + body, + } + }) + } +} + +impl UpstreamWebSocket { + /// 发送完整文本或二进制消息。 + /// + /// # Errors + /// 连接关闭、消息无效、网络失败或期限到达时失败。 + pub async fn send( + &mut self, + kind: WebSocketMessageKind, + body: Vec, + ) -> Result<(), PluginFault> { + if self.closed { + return Err(PluginFault::new( + ErrorCode::InvalidInput, + "WebSocket is closed", + )); + } + empty_callback( + &self.host, + "host.upstream.websocket.send", + UpstreamWebSocketMessage { kind }, + body, + ) + .await + } + + /// 按需读取完整消息,EOF 后再次读取返回 None;Ping/Pong 由宿主处理。 + /// + /// # Errors + /// 父调用结束、网络失败或期限到达时失败。 + pub async fn read(&mut self) -> Result)>, PluginFault> { + if self.closed { + return Ok(None); + } + let (read, body): (UpstreamWebSocketRead, _) = callback( + &self.host, + "host.upstream.websocket.read", + serde_json::json!({}), + vec![], + ) + .await?; + match (read.eof, read.kind) { + (true, None) if body.is_empty() => { + self.closed = true; + Ok(None) + } + (false, Some(kind)) => Ok(Some((kind, body))), + _ => Err(invalid()), + } + } + + /// 提前关闭并放弃连接内续接,重复关闭不调用宿主。 + /// + /// # Errors + /// 父调用已结束或宿主拒绝释放时失败。 + pub async fn close(&mut self) -> Result<(), PluginFault> { + if self.closed { + return Ok(()); + } + empty_callback( + &self.host, + "host.upstream.websocket.close", + serde_json::json!({}), + vec![], + ) + .await?; + self.closed = true; + Ok(()) + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/manifest.rs b/backend/crates/gateway-plugin/sdk/src/manifest.rs index 9c6e0e52f..a357b092d 100644 --- a/backend/crates/gateway-plugin/sdk/src/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/src/manifest.rs @@ -261,8 +261,9 @@ 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 - || (*capability == Capability::Middleware && declaration.version == 2)) + if !capability + .contract_versions() + .contains(&declaration.version) || declaration.id.len() > 128 || local_id.is_empty() || !local_id.is_ascii() diff --git a/backend/crates/gateway-plugin/sdk/tests/client/http.rs b/backend/crates/gateway-plugin/sdk/tests/client/http.rs new file mode 100644 index 000000000..d809b496f --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/client/http.rs @@ -0,0 +1,223 @@ +use super::session::{HostPeer, receive, send_call, shutdown, start_session}; +use gateway_plugin_sdk::client::{CallFuture, CallReply, PluginCall, PluginHandler, write_frame}; +use gateway_plugin_sdk::{ErrorCode, Frame, Message}; +use gateway_plugin_sdk::{ + call::{ + host::HttpRequest, + upstream_adapter::{UpstreamHttpRequest, UpstreamWebSocketRequest, WebSocketMessageKind}, + }, + client::UpstreamWebSocketUpgrade, +}; +use serde_json::{Value, json}; + +struct NetworkHandler; + +impl PluginHandler for NetworkHandler { + fn call(&self, call: PluginCall) -> CallFuture<'_> { + Box::pin(async move { + if call.method == "websocket" { + let upgrade = call + .host + .upstream_websocket(UpstreamWebSocketRequest { + path: "responses".into(), + query: vec![], + headers: vec![], + }) + .await?; + let UpstreamWebSocketUpgrade::Connected { mut connection, .. } = upgrade else { + let UpstreamWebSocketUpgrade::Rejected { status, body, .. } = upgrade else { + unreachable!() + }; + return Ok(CallReply::unary(json!({"status":status}), body)); + }; + connection + .send(WebSocketMessageKind::Text, b"request".to_vec()) + .await?; + let (kind, body) = connection.read().await?.unwrap(); + assert_eq!(kind, WebSocketMessageKind::Text); + assert!(connection.read().await?.is_none()); + assert!(connection.read().await?.is_none()); + connection.close().await?; + assert!(connection.send(kind, vec![]).await.is_err()); + return Ok(CallReply::unary(json!({}), body)); + } + let response = if call.method == "http" { + call.host + .http( + HttpRequest { + method: "POST".into(), + url: "https://example.test/responses".into(), + headers: vec![], + }, + call.payload, + ) + .await? + } else { + call.host + .upstream_http( + UpstreamHttpRequest { + method: "POST".into(), + path: "responses".into(), + query: vec![], + headers: vec![], + }, + call.payload, + ) + .await? + }; + assert_eq!(response.status, 200); + if call.params["collect"] == true { + return Ok(CallReply::unary(json!({}), response.body.collect(3).await?)); + } + let mut body = response.body; + let bytes = body.read().await?.unwrap(); + assert!(body.read().await?.is_none()); + assert!(body.read().await?.is_none()); + body.close().await?; + body.close().await?; + Ok(CallReply::unary(json!({}), bytes)) + }) + } +} + +async fn reply_callback( + host: &mut HostPeer, + expected: &str, + result: Value, + payload: Vec, +) -> Frame { + let frame = receive(host).await; + let Message::Callback { id, method, .. } = &frame.message else { + panic!("expected callback") + }; + assert_eq!(method, expected); + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { id: *id, result }, + payload, + }, + ) + .await + .unwrap(); + frame +} + +#[tokio::test] +async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() { + for method in ["http", "upstream"] { + let (mut host, task) = start_session(NetworkHandler).await; + send_call(&mut host, 1, method, json!({}), b"request".to_vec()).await; + let prefix = if method == "http" { + "host.http" + } else { + "host.upstream.http" + }; + let frame = reply_callback( + &mut host, + &format!("{prefix}.do_stream"), + json!({"status":200,"headers":[],"stream":"private-handle"}), + vec![], + ) + .await; + assert_eq!(frame.payload, b"request"); + let frame = reply_callback( + &mut host, + &format!("{prefix}.stream_read"), + json!({"eof":false}), + b"abc".to_vec(), + ) + .await; + assert!( + matches!(frame.message, Message::Callback { params, .. } if params == json!({"stream":"private-handle","maximum_bytes":65536})) + ); + reply_callback( + &mut host, + &format!("{prefix}.stream_read"), + json!({"eof":true}), + vec![], + ) + .await; + let result = receive(&mut host).await; + assert!(matches!(result.message, Message::Result { id: 1, .. })); + assert_eq!(result.payload, b"abc"); + shutdown(&mut host, task).await; + } +} + +#[tokio::test] +async fn bounded_collection_closes_the_host_stream_before_reporting_overflow() { + let (mut host, task) = start_session(NetworkHandler).await; + send_call(&mut host, 1, "upstream", json!({"collect":true}), vec![]).await; + reply_callback( + &mut host, + "host.upstream.http.do_stream", + json!({"status":200,"headers":[],"stream":"private-handle"}), + vec![], + ) + .await; + reply_callback( + &mut host, + "host.upstream.http.stream_read", + json!({"eof":false}), + b"abcd".to_vec(), + ) + .await; + reply_callback( + &mut host, + "host.upstream.http.stream_close", + json!({}), + vec![], + ) + .await; + assert!( + matches!(receive(&mut host).await.message, Message::Error { id: 1, error } if error.code == ErrorCode::Capacity) + ); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn websocket_session_handles_messages_eof_and_http_rejection() { + let (mut host, task) = start_session(NetworkHandler).await; + send_call(&mut host, 1, "websocket", json!({}), vec![]).await; + reply_callback( + &mut host, + "host.upstream.websocket.open", + json!({"status":101,"headers":[],"stream":null}), + vec![], + ) + .await; + let sent = reply_callback(&mut host, "host.upstream.websocket.send", json!({}), vec![]).await; + assert_eq!(sent.payload, b"request"); + reply_callback( + &mut host, + "host.upstream.websocket.read", + json!({"eof":false,"kind":"text"}), + b"response".to_vec(), + ) + .await; + reply_callback( + &mut host, + "host.upstream.websocket.read", + json!({"eof":true,"kind":null}), + vec![], + ) + .await; + let response = receive(&mut host).await; + assert!(matches!(response.message, Message::Result { id: 1, .. })); + assert_eq!(response.payload, b"response"); + send_call(&mut host, 3, "websocket", json!({}), vec![]).await; + reply_callback( + &mut host, + "host.upstream.websocket.open", + json!({"status":401,"headers":[],"stream":null}), + b"unauthorized".to_vec(), + ) + .await; + let response = receive(&mut host).await; + assert!( + matches!(response.message, Message::Result { id: 3, result } if result == json!({"status":401})) + ); + assert_eq!(response.payload, b"unauthorized"); + shutdown(&mut host, task).await; +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs index 37047b2dc..ab7877ff3 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs @@ -1,2 +1,3 @@ +mod http; mod plugin; mod session; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session.rs b/backend/crates/gateway-plugin/sdk/tests/client/session.rs index e493bb752..4c225c643 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session.rs @@ -301,12 +301,12 @@ impl PluginHandler for TestHandler { } } -struct HostPeer { - reader: ReadHalf, - writer: WriteHalf, +pub(super) struct HostPeer { + pub(super) reader: ReadHalf, + pub(super) writer: WriteHalf, } -async fn start_session( +pub(super) async fn start_session( handler: H, ) -> (HostPeer, JoinHandle>) { start_session_with_capacity(handler, MAXIMUM_STREAM_CHUNK_BYTES * 2).await @@ -603,7 +603,13 @@ fn middleware_request() -> MiddlewareRequestHead { } } -async fn send_call(host: &mut HostPeer, id: u64, method: &str, params: Value, payload: Vec) { +pub(super) async fn send_call( + host: &mut HostPeer, + id: u64, + method: &str, + params: Value, + payload: Vec, +) { send_call_with_timeout(host, id, method, params, payload, Duration::from_secs(1)).await; } @@ -637,14 +643,14 @@ async fn send_control(host: &mut HostPeer, message: Message) { .unwrap(); } -async fn receive(host: &mut HostPeer) -> Frame { +pub(super) async fn receive(host: &mut HostPeer) -> Frame { tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader)) .await .expect("plugin response timed out") .expect("plugin response frame must be valid") } -async fn shutdown(host: &mut HostPeer, task: JoinHandle>) { +pub(super) async fn shutdown(host: &mut HostPeer, task: JoinHandle>) { send_control(host, Message::Shutdown).await; tokio::time::timeout(Duration::from_secs(1), task) .await diff --git a/backend/crates/gateway-plugin/sdk/tests/manifest.rs b/backend/crates/gateway-plugin/sdk/tests/manifest.rs index 2150ce07b..adafaf2bc 100644 --- a/backend/crates/gateway-plugin/sdk/tests/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/tests/manifest.rs @@ -126,6 +126,40 @@ fn installation_checks_protocol_engine_and_target() { assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); } +#[test] +fn capability_contract_versions_are_checked_independently_of_rpc_version() { + let mut manifest = packaged_manifest(); + for version in [1, 2] { + manifest + .contributes + .get_mut(&Capability::Middleware) + .unwrap() + .version = version; + assert!(manifest.validate().is_ok()); + } + for version in [0, 3, u32::MAX] { + manifest + .contributes + .get_mut(&Capability::Middleware) + .unwrap() + .version = version; + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + } + + manifest.contributes.clear(); + manifest.contributes.insert( + Capability::Management, + ContributionDeclaration { + id: "9acme.request-tags.management".into(), + version: 2, + stages: vec![Stage::Management], + input_formats: vec![], + output_formats: vec![], + }, + ); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + #[test] fn package_rejects_file_directory_conflicts_and_missing_declared_files() { let mut manifest = packaged_manifest(); diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 19352c686..2c112b931 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -103,6 +103,7 @@ use crate::transport::{ mod execution; mod failure; mod observation; +mod upstream_adapter; mod workers; pub(crate) use workers::ClientReleaseServices; @@ -472,9 +473,18 @@ impl Provider for CodexProvider { UpstreamSendState::NotSent, )); }; + let adapter = context.upstream_adapter(candidate.provider(), upstream_model)?; + if adapter.is_none() + && generate + .provider_session_state(PROVIDER_NAME) + .is_some_and(|state| state.extension_owner().is_some()) + { + return Err(continuation_replay_required_error("scope_unavailable")); + } // 其他协议必须先取得真实账号,再按固定 attempt 阶段调用转换器;选号前不能 // 把未知正文当成 OpenAI wire 解释会话、亲和或传输字段。 - let preselection = (generate.protocol_payload().protocol() == PROVIDER_NAME) + let preselection = (adapter.is_none() + && generate.protocol_payload().protocol() == PROVIDER_NAME) .then(|| self.prepare_generate_request(generate, upstream_model, &context)) .transpose()?; let (selection_session_affinity, selection_cyber_policy_key, requires_websocket) = @@ -539,6 +549,18 @@ impl Provider for CodexProvider { account_id, Box::new(move |operation, middleware_headers| { Box::pin(async move { + if let Some(adapter) = adapter { + return provider.execute_upstream_adapter( + operation, + middleware_headers, + terminal_model, + terminal_context, + lease, + account_selection_wait_ms, + frozen_requirements, + adapter, + ); + } provider .execute_selected_generate( operation, diff --git a/backend/crates/providers/openai/src/provider/upstream_adapter.rs b/backend/crates/providers/openai/src/provider/upstream_adapter.rs new file mode 100644 index 000000000..92ca584a9 --- /dev/null +++ b/backend/crates/providers/openai/src/provider/upstream_adapter.rs @@ -0,0 +1,206 @@ +//! 插件只替换业务 wire;账号租约、凭据解释与费用来源仍属于 OpenAI。 + +use super::*; +use crate::transport::usage::{OpenAiBillingUsage, openai_billing_breakdown_with_override}; +use gateway_core::{ + account::{CredentialRevision, OutboundProxy, ProviderAccountId}, + engine::upstream_adapter::{ + UpstreamAccountConnection, UpstreamAdapter, UpstreamAdapterInvocation, + }, + metering::{CalculatedCost, Usage}, +}; + +struct SelectedConnection { + provider: Arc, + lease: Arc, + context: AttemptContext, + model: UpstreamModelId, + requested_service_tier: Option, +} + +impl CodexProvider { + // 与原生 terminal 共用一次选号结果,不能为适配器重新获取账号或租约。 + #[expect(clippy::too_many_arguments)] + pub(super) fn execute_upstream_adapter( + self: Arc, + mut operation: Operation, + headers: Vec, + model: UpstreamModelId, + context: AttemptContext, + lease: CodexCredentialLease, + account_selection_wait_ms: u64, + requirements: CapabilityRequirements, + adapter: Arc, + ) -> Result { + if operation.capability_requirements() != requirements { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + if context.disable_fast() + && let Operation::Generate(generate) = &operation + && generate.protocol_payload().protocol() == PROVIDER_NAME + { + // 适配器终端同样在 attempt 加工后应用原生强制策略,不另写一套档位规则。 + let mut request = + CodexResponsesRequest::from_body(generate.protocol_payload().body().clone()); + request.apply_fast_policy(true); + let body = serde_json::to_vec(request.body()).map_err(|_| { + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + })?; + operation = operation + .replace_middleware_wire(PROVIDER_NAME, body.into()) + .map_err(|_| { + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + })?; + } + let requested_service_tier = match &operation { + Operation::Generate(generate) + if generate.protocol_payload().protocol() == PROVIDER_NAME => + { + let request = + CodexResponsesRequest::from_body(generate.protocol_payload().body().clone()); + normalize_service_tier(request.service_tier()) + } + _ => None, + }; + let lease = Arc::new(lease); + let metadata = ProviderCallMetadata::new( + ProviderKind::new(PROVIDER_NAME).map_err(|_| { + provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent) + })?, + model.clone(), + lease.account_id().clone(), + UpstreamTransport::new(adapter.transport()).map_err(|_| { + provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent) + })?, + ) + .with_selection_observation(ProviderSelectionObservation::new( + account_selection_wait_ms, + lease.capacity_snapshot(), + )); + let account = Arc::new(SelectedConnection { + provider: Arc::clone(&self), + lease: Arc::clone(&lease), + context: context.clone(), + model, + requested_service_tier, + }); + let events = adapter.execute(UpstreamAdapterInvocation { + operation, + headers, + context, + metadata: metadata.clone(), + account, + }); + let mutation = lease.allows_account_state_mutation(); + let stream = ProviderStream::new(metadata, events, lease); + Ok(if mutation { + stream.with_filtered_account_feedback( + Arc::clone(&self.account_feedback), + openai_failure_affects_account_score, + ) + } else { + stream + }) + } +} + +impl UpstreamAccountConnection for SelectedConnection { + fn account_id(&self) -> &ProviderAccountId { + self.lease.account_id() + } + fn credential_revision(&self) -> CredentialRevision { + self.lease.account().revision() + } + fn authentication_kind(&self) -> &str { + self.lease.account().authentication_kind() + } + fn outbound_proxy(&self) -> Option<&OutboundProxy> { + self.lease.account().outbound_proxy() + } + + fn authorization(&self) -> Result, ProviderError> { + let authorization = self + .lease + .authentication() + .authorization_header() + .map_err(|_| { + provider_error(ProviderErrorKind::Unauthorized, UpstreamSendState::NotSent) + })?; + let mut headers = vec![MiddlewareHeader::new( + "authorization", + bytes::Bytes::copy_from_slice(authorization.expose_secret().as_bytes()), + )]; + if let Some(account_id) = self.lease.account().upstream_account_id() { + headers.push(MiddlewareHeader::new( + "chatgpt-account-id", + bytes::Bytes::copy_from_slice(account_id.as_bytes()), + )); + } + // 业务目标可能与原生 Responses 不同,不能把原域名的 Cookie 带到新目标。 + Ok(headers) + } + + fn calculate_cost(&self, _: Option<&str>, usage: &Usage) -> Option { + // 与原生路径一样按请求策略计价;响应回显只作观测,不能切换模型或档位。 + let usage = gateway_protocol::openai::events::TokenUsage { + input_tokens: usage.input_tokens?, + output_tokens: usage.output_tokens?, + cached_tokens: usage.cached_tokens.unwrap_or(0), + cache_write_tokens: usage.cache_write_tokens.unwrap_or(0), + image_input_tokens: usage.image_input_tokens.unwrap_or(0), + image_output_tokens: usage.image_output_tokens.unwrap_or(0), + ..Default::default() + }; + openai_billing_breakdown_with_override( + self.model.as_str(), + OpenAiBillingUsage::from(usage), + self.requested_service_tier.as_deref(), + self.context + .pricing() + .get(PROVIDER_NAME) + .and_then(|models| models.get(self.model.as_str())), + ) + .map(|cost| cost.calculated_cost()) + } + + fn record_failure(&self, error: ProviderError) -> BoxFuture<'_, ProviderError> { + Box::pin(async move { + if !self.lease.allows_account_state_mutation() + || error.send_state() == UpstreamSendState::NotSent + { + return error; + } + let failure = match error.kind() { + ProviderErrorKind::Unauthorized => Some(CodexAccountFailure::CredentialExpired), + ProviderErrorKind::RateLimited => Some(CodexAccountFailure::RateLimited { + retry_after: error.retry_after(), + }), + ProviderErrorKind::QuotaExhausted => Some(CodexAccountFailure::QuotaExhausted), + _ => None, + }; + if let Some(failure) = failure { + let _ = self + .provider + .selector + .record_failure(self.lease.account(), failure, None) + .await; + if error.kind() == ProviderErrorKind::QuotaExhausted { + schedule_authoritative_quota_refresh_after_failure( + &self.provider.quota, + self.lease.account(), + ); + } + } + error + }) + } +} diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 5fafbe499..f1c319a54 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -2,6 +2,7 @@ mod account_isolation; mod capacity; mod precommit; mod response_interrupt; +mod upstream_adapter; use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, Write}; diff --git a/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs b/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs new file mode 100644 index 000000000..8144954ee --- /dev/null +++ b/backend/crates/providers/openai/tests/provider/contract/upstream_adapter.rs @@ -0,0 +1,346 @@ +use super::*; +use gateway_core::engine::upstream_adapter::{ + UpstreamAccountConnection, UpstreamAdapter, UpstreamAdapterInvocation, UpstreamAdapterPlan, +}; + +#[derive(Debug)] +struct AdapterProbe { + polls: Arc, +} + +impl gateway_core::engine::upstream_adapter::UpstreamAdapterPlan for AdapterProbe { + fn select( + &self, + _: &AttemptContext, + provider: &ProviderKind, + _: &UpstreamModelId, + ) -> Result< + Option>, + gateway_core::error::ProviderError, + > { + assert_eq!(provider.as_str(), "openai"); + Ok(Some(Arc::new(Self { + polls: self.polls.clone(), + }))) + } +} + +impl gateway_core::engine::upstream_adapter::UpstreamAdapter for AdapterProbe { + fn transport(&self) -> &str { + "http_sse" + } + + fn execute( + self: Arc, + invocation: gateway_core::engine::upstream_adapter::UpstreamAdapterInvocation, + ) -> gateway_core::engine::provider::EventStream { + Box::pin(futures::stream::once(async move { + self.polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + invocation.account.account_id().as_str(), + "acct_provider_contract" + ); + assert_eq!( + invocation.metadata.provider_account_id(), + invocation.account.account_id() + ); + assert_eq!(invocation.account.authentication_kind(), "oauth"); + let headers = invocation.account.authorization().unwrap(); + let header = |name: &str| { + headers + .iter() + .find(|h| h.name() == name) + .map(|h| h.value().to_vec()) + }; + assert_eq!( + header("authorization"), + Some("Bearer at-acct_provider_contract".as_bytes().to_vec()) + ); + assert_eq!( + header("chatgpt-account-id"), + Some("chatgpt-acct_provider_contract".as_bytes().to_vec()) + ); + assert!(header("cookie").is_none()); + assert!(invocation.context.disable_fast()); + let Operation::Generate(generate) = &invocation.operation else { + panic!("adapter must receive the original operation kind"); + }; + assert_eq!( + generate.protocol_payload().body()["service_tier"], + "default" + ); + assert!( + invocation + .headers + .iter() + .any(|h| h.name() == "x-adapter-onion") + ); + assert!( + invocation + .account + .calculate_cost( + None, + &gateway_core::metering::Usage { + input_tokens: Some(7), + output_tokens: Some(2), + ..Default::default() + } + ) + .is_some() + ); + let usage = gateway_core::metering::Usage { + input_tokens: Some(7), + output_tokens: Some(2), + ..Default::default() + }; + assert_eq!( + invocation.account.calculate_cost(Some("priority"), &usage), + invocation.account.calculate_cost(Some("default"), &usage), + "响应回显不得覆盖已冻结的禁用 Fast 策略" + ); + Err(gateway_core::error::ProviderError::new( + ProviderErrorKind::Cancelled, + UpstreamSendState::NotSent, + )) + })) + } +} + +#[tokio::test] +async fn upstream_adapter_reuses_selected_native_account_inside_attempt_onion_and_stays_cold() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let provider = provider_with_base_url(&store, server.uri()); + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lease = ExtensionSetReference::new( + ExtensionSetId::new("adapter-native-test".to_owned()).unwrap(), + Arc::new(TestExtensionLease), + ); + let middleware = Arc::new(RecordingMiddleware { + observed: Default::default(), + replacement: ("service_tier".into(), json!("priority")), + request_headers: vec![MiddlewareHeader::new( + "x-adapter-onion", + Bytes::from_static(b"present"), + )], + }); + let context = AttemptContext::new( + gateway_core::engine::RequestAttemptContext::new( + ModelRequestId::new("req_adapter_native").unwrap(), + ClientApiKeyId::new("key_openai_contract").unwrap(), + ) + .with_disable_fast(true) + .with_upstream_adapters(Some( + gateway_core::engine::upstream_adapter::FrozenUpstreamAdapterPlan::new( + Arc::new(AdapterProbe { + polls: polls.clone(), + }), + lease.clone(), + ), + )) + .with_middleware( + Some(FrozenMiddlewarePlan::new(middleware, lease)), + Arc::from([]), + "/v1/responses".to_owned(), + ClientTransport::HttpSse, + ), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(5), + account_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(contract_account_scope()), + None, + CancellationToken::new(), + ); + let mut stream = provider + .execute(planned_request("openai", generate_operation()), context) + .await + .unwrap(); + assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), 0); + let error = stream.next().await.unwrap().unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Cancelled); + assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +type SelectedConnection = Arc; + +struct ConnectionProbe { + selected: Arc>>>, +} + +impl std::fmt::Debug for ConnectionProbe { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConnectionProbe") + .finish_non_exhaustive() + } +} + +impl UpstreamAdapterPlan for ConnectionProbe { + fn select( + &self, + _: &AttemptContext, + _: &ProviderKind, + _: &UpstreamModelId, + ) -> Result>, gateway_core::error::ProviderError> { + Ok(Some(Arc::new(Self { + selected: Arc::clone(&self.selected), + }))) + } +} + +impl UpstreamAdapter for ConnectionProbe { + fn transport(&self) -> &str { + "http_sse" + } + + fn execute( + self: Arc, + invocation: UpstreamAdapterInvocation, + ) -> gateway_core::engine::provider::EventStream { + Box::pin(futures::stream::once(async move { + assert!( + self.selected + .lock() + .unwrap() + .take() + .unwrap() + .send(invocation.account) + .is_ok() + ); + Err(gateway_core::error::ProviderError::new( + ProviderErrorKind::Cancelled, + UpstreamSendState::NotSent, + )) + })) + } +} + +async fn selected_adapter_connection( + provider: Arc, + request_id: &str, +) -> SelectedConnection { + let (selected, receiver) = oneshot::channel(); + let plan = gateway_core::engine::upstream_adapter::FrozenUpstreamAdapterPlan::new( + Arc::new(ConnectionProbe { + selected: Arc::new(Mutex::new(Some(selected))), + }), + ExtensionSetReference::new( + ExtensionSetId::new("adapter-credential-test".to_owned()).unwrap(), + Arc::new(TestExtensionLease), + ), + ); + let context = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new(request_id).unwrap(), + ClientApiKeyId::new("key_openai_contract").unwrap(), + ) + .with_upstream_adapters(Some(plan)), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(5), + account_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(contract_account_scope()), + None, + CancellationToken::new(), + ); + let mut stream = provider + .execute(planned_request("openai", generate_operation()), context) + .await + .unwrap(); + assert_eq!( + stream.next().await.unwrap().unwrap_err().kind(), + ProviderErrorKind::Cancelled + ); + receiver.await.unwrap() +} + +#[tokio::test] +async fn upstream_adapter_reloads_rotated_credentials_and_proxy_without_accepting_stale_failure() { + use gateway_core::account::OutboundProxy; + + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_provider_contract"; + create_account(&store, account_id).await; + let old_proxy = OutboundProxy::parse("http://127.0.0.1:18001").unwrap(); + let new_proxy = OutboundProxy::parse("http://127.0.0.1:18002").unwrap(); + store.set_egress(account_id, Some(old_proxy.clone()), None); + let upstream = MockServer::start().await; + let provider = provider_with_base_url(&store, upstream.uri()); + let old = + selected_adapter_connection(Arc::clone(&provider), "req_adapter_before_refresh").await; + let before = store.account(account_id).unwrap(); + assert_eq!(old.outbound_proxy(), Some(&old_proxy)); + + // 使用原生刷新成功后的 CAS 入口;适配器不能复制令牌或缓存第二份账号代理。 + let revision = store + .repository() + .rotate_refreshed_oauth_secret( + &before, + secret("rotated-adapter-access-token"), + Some(SystemTime::now() + Duration::from_secs(3600)), + None, + ) + .await + .unwrap(); + store.set_egress(account_id, Some(new_proxy.clone()), None); + let current = selected_adapter_connection(provider, "req_adapter_after_refresh").await; + assert_eq!(current.account_id(), old.account_id()); + assert_eq!(current.credential_revision(), revision); + assert_ne!(current.credential_revision(), old.credential_revision()); + assert_eq!(current.outbound_proxy(), Some(&new_proxy)); + assert_eq!(old.outbound_proxy(), Some(&old_proxy)); + for (connection, expected) in [ + (&old, b"Bearer at-acct_provider_contract".as_slice()), + (¤t, b"Bearer rotated-adapter-access-token".as_slice()), + ] { + let headers = connection.authorization().unwrap(); + assert_eq!( + headers + .iter() + .find(|header| header.name() == "authorization") + .unwrap() + .value(), + expected + ); + assert_eq!( + headers + .iter() + .find(|header| header.name() == "chatgpt-account-id") + .unwrap() + .value(), + b"chatgpt-acct_provider_contract".as_slice() + ); + } + + // 旧请求的 401 不能使已轮换凭据失效,未发送失败也不能污染账号状态。 + for (connection, send_state) in [ + (&old, UpstreamSendState::Sent), + (¤t, UpstreamSendState::NotSent), + ] { + let error = connection + .record_failure(gateway_core::error::ProviderError::new( + ProviderErrorKind::Unauthorized, + send_state, + )) + .await; + assert_eq!(error.kind(), ProviderErrorKind::Unauthorized); + assert_eq!(error.send_state(), send_state); + let after = store.account(account_id).unwrap(); + assert_eq!(after.revision(), revision); + assert_eq!(after.credential_state(), CredentialState::Ready); + } + current + .record_failure(gateway_core::error::ProviderError::new( + ProviderErrorKind::Unauthorized, + UpstreamSendState::Sent, + )) + .await; + assert_eq!( + store.account(account_id).unwrap().credential_state(), + CredentialState::Expired + ); + assert!(upstream.received_requests().await.unwrap().is_empty()); +} diff --git a/backend/crates/providers/xai/src/provider/mod.rs b/backend/crates/providers/xai/src/provider/mod.rs index 09b4200d2..f7e16fc98 100644 --- a/backend/crates/providers/xai/src/provider/mod.rs +++ b/backend/crates/providers/xai/src/provider/mod.rs @@ -72,6 +72,7 @@ use crate::{GrokCatalogCapabilityEvidence, GrokCatalogModel}; mod continuation; mod failure; mod stream; +mod upstream_adapter; mod workers; use continuation::*; @@ -266,8 +267,17 @@ impl GrokBuildProvider { candidate: &ProviderCandidate, context: AttemptContext, ) -> Result { + let adapter = + context.upstream_adapter(candidate.provider(), candidate_upstream_model(candidate)?)?; + if adapter.is_none() + && generate + .provider_session_state(XAI_PROVIDER_NAME) + .is_some_and(|state| state.extension_owner().is_some()) + { + return Err(invalid_continuation()); + } let selected = self - .select_grok_attempt(generate, candidate, context) + .select_grok_attempt(generate, candidate, context, adapter.is_some()) .await?; let provider_kind = ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?; @@ -283,6 +293,14 @@ impl GrokBuildProvider { account_id, Box::new(move |operation, middleware_headers| { Box::pin(async move { + if let Some(adapter) = adapter { + return provider.execute_upstream_adapter( + operation, + middleware_headers, + selected, + adapter, + ); + } provider .execute_selected_grok(operation, middleware_headers, selected) .await @@ -297,10 +315,15 @@ impl GrokBuildProvider { generate: &GenerateRequest, candidate: &ProviderCandidate, context: AttemptContext, + adapted: bool, ) -> Result { let upstream_model = candidate_upstream_model(candidate)?; - let previous_session = decode_xai_session_state(generate)?; - let native_source = generate.protocol_payload().protocol() == "openai"; + let previous_session = if adapted { + None + } else { + decode_xai_session_state(generate)? + }; + let native_source = !adapted && generate.protocol_payload().protocol() == "openai"; let native_compaction = native_source && crate::transport::compaction::has_terminal_compaction_trigger(generate); let (selection_model, operation_account, affinity) = if native_compaction { diff --git a/backend/crates/providers/xai/src/provider/upstream_adapter.rs b/backend/crates/providers/xai/src/provider/upstream_adapter.rs new file mode 100644 index 000000000..47af203ba --- /dev/null +++ b/backend/crates/providers/xai/src/provider/upstream_adapter.rs @@ -0,0 +1,137 @@ +//! xAI 已选 OAuth 会话的受管连接;插件不读取令牌或重新选号。 + +use super::*; +use gateway_core::{ + account::{CredentialRevision, OutboundProxy}, + engine::upstream_adapter::{ + UpstreamAccountConnection, UpstreamAdapter, UpstreamAdapterInvocation, + }, + metering::{CalculatedCost, Usage}, +}; + +struct SelectedConnection { + provider: Arc, + session: Arc, + context: AttemptContext, + model: UpstreamModelId, +} + +impl GrokBuildProvider { + pub(super) fn execute_upstream_adapter( + self: Arc, + operation: Operation, + headers: Vec, + selected: SelectedGrokAttempt, + adapter: Arc, + ) -> Result { + if operation.capability_requirements() != selected.frozen_requirements { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + let session = Arc::new(selected.selected); + let metadata = ProviderCallMetadata::new( + ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?, + selected.upstream_model.clone(), + session.account_id().clone(), + UpstreamTransport::new(adapter.transport()).map_err(|_| protocol_not_sent())?, + ) + .with_selection_observation(ProviderSelectionObservation::new( + selected.account_selection_wait_ms, + session.capacity_snapshot(), + )); + let account = Arc::new(SelectedConnection { + provider: Arc::clone(&self), + session: Arc::clone(&session), + context: selected.context.clone(), + model: selected.upstream_model, + }); + let events = adapter.execute(UpstreamAdapterInvocation { + operation, + headers, + context: selected.context, + metadata: metadata.clone(), + account, + }); + let mutation = session.allows_account_state_mutation(); + let stream = ProviderStream::new(metadata, events, session); + Ok(if mutation { + // 插件协议或 RPC 故障不能降低原生账号评分;只接纳已解析的上游拒绝事实。 + stream.with_filtered_account_feedback(Arc::clone(&self.account_feedback), |error| { + error.client_visible_upstream_error().is_some() + }) + } else { + stream + }) + } +} + +impl UpstreamAccountConnection for SelectedConnection { + fn account_id(&self) -> &ProviderAccountId { + self.session.account_id() + } + fn credential_revision(&self) -> CredentialRevision { + self.session.credential_revision() + } + fn authentication_kind(&self) -> &str { + crate::credential::XAI_AUTHENTICATION_KIND_OAUTH + } + fn outbound_proxy(&self) -> Option<&OutboundProxy> { + self.session.binding().outbound_proxy() + } + + fn authorization(&self) -> Result, ProviderError> { + Ok(vec![ + MiddlewareHeader::new( + "authorization", + bytes::Bytes::from(format!("Bearer {}", self.session.access_token().expose())), + ), + MiddlewareHeader::new( + "x-grok-user-id", + bytes::Bytes::copy_from_slice(self.session.user_id().expose().as_bytes()), + ), + MiddlewareHeader::new( + "x-xai-token-auth", + bytes::Bytes::from_static(b"xai-grok-cli"), + ), + ]) + } + + fn calculate_cost(&self, tier: Option<&str>, usage: &Usage) -> Option { + crate::transport::canonical::grok_billing_breakdown_with_override( + self.model.as_str(), + usage.input_tokens?, + usage.output_tokens?, + usage.cached_tokens.unwrap_or(0), + usage.cache_write_tokens.unwrap_or(0), + tier, + self.context + .pricing() + .get(XAI_PROVIDER_NAME) + .and_then(|models| models.get(self.model.as_str())), + ) + .map(|cost| cost.calculated_cost()) + } + + fn record_failure(&self, mut error: ProviderError) -> BoxFuture<'_, ProviderError> { + Box::pin(async move { + if error.send_state() == UpstreamSendState::NotSent { + return error; + } + if error.kind() == ProviderErrorKind::Unauthorized { + error = error.with_credential_recovery(); + } + let failure = stream_credential_failure(&error, &self.model); + recover_or_record_failure( + self.provider.selector.as_ref(), + self.provider.credential_recovery.as_ref(), + &self.session, + error, + failure, + self.context.credential_recovery_attempted(), + ) + .await + }) + } +} diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 62a5c54a2..9f55dbb00 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -4150,3 +4150,141 @@ async fn configured_request_profile_reaches_inference_headers() { assert_eq!(header("x-grok-client-mode"), "headless"); assert_eq!(header("user-agent"), "grok-shell/9.8.7 (windows; aarch64)"); } + +#[derive(Debug)] +struct AdapterProbe { + polls: Arc, +} + +impl gateway_core::engine::upstream_adapter::UpstreamAdapterPlan for AdapterProbe { + fn select( + &self, + _: &AttemptContext, + provider: &ProviderKind, + _: &UpstreamModelId, + ) -> Result< + Option>, + gateway_core::error::ProviderError, + > { + assert_eq!(provider.as_str(), "xai"); + Ok(Some(Arc::new(Self { + polls: self.polls.clone(), + }))) + } +} + +impl gateway_core::engine::upstream_adapter::UpstreamAdapter for AdapterProbe { + fn transport(&self) -> &str { + "http_sse" + } + + fn execute( + self: Arc, + invocation: gateway_core::engine::upstream_adapter::UpstreamAdapterInvocation, + ) -> gateway_core::engine::provider::EventStream { + Box::pin(futures::stream::once(async move { + self.polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!(invocation.account.account_id().as_str(), "acct_provider"); + assert_eq!( + invocation.metadata.provider_account_id(), + invocation.account.account_id() + ); + assert_eq!(invocation.account.authentication_kind(), "oauth"); + let headers = invocation.account.authorization().unwrap(); + let header = |name: &str| { + headers + .iter() + .find(|h| h.name() == name) + .map(|h| h.value().to_vec()) + }; + assert_eq!( + header("authorization"), + Some("Bearer oauth-access".as_bytes().to_vec()) + ); + assert_eq!( + header("x-grok-user-id"), + Some("verified-user".as_bytes().to_vec()) + ); + assert!(header("cookie").is_none()); + assert!( + invocation + .headers + .iter() + .any(|h| h.name() == "x-adapter-onion") + ); + assert!( + invocation + .account + .calculate_cost( + None, + &gateway_core::metering::Usage { + input_tokens: Some(7), + output_tokens: Some(2), + ..Default::default() + } + ) + .is_some() + ); + Err(gateway_core::error::ProviderError::new( + ProviderErrorKind::Cancelled, + UpstreamSendState::NotSent, + )) + })) + } +} + +#[tokio::test] +async fn upstream_adapter_reuses_selected_native_account_inside_attempt_onion_and_stays_cold() { + let selector = StubSelector::success(); + let transport = StubInferenceTransport::success(); + let provider = provider(selector.clone(), transport.clone()).await; + let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lease = ExtensionSetReference::new( + ExtensionSetId::new("adapter-native-test".to_owned()).unwrap(), + Arc::new(TestExtensionLease), + ); + let middleware = Arc::new(RecordingMiddleware { + observed: Default::default(), + replacement: Some(("input".into(), json!("rewritten"))), + request_headers: vec![MiddlewareHeader::new( + "x-adapter-onion", + Bytes::from_static(b"present"), + )], + }); + let context = AttemptContext::new( + gateway_core::engine::RequestAttemptContext::new( + ModelRequestId::new("req_adapter_native").unwrap(), + ClientApiKeyId::new("key_xai_contract").unwrap(), + ) + .with_upstream_adapters(Some( + gateway_core::engine::upstream_adapter::FrozenUpstreamAdapterPlan::new( + Arc::new(AdapterProbe { + polls: polls.clone(), + }), + lease.clone(), + ), + )) + .with_middleware( + Some(FrozenMiddlewarePlan::new(middleware, lease)), + Arc::from([]), + "/v1/responses".to_owned(), + ClientTransport::HttpSse, + ), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(5), + selection_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None), + None, + CancellationToken::new(), + ); + let mut stream = provider + .execute(provider_request("xai"), context) + .await + .unwrap(); + assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), 0); + let error = stream.next().await.unwrap().unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Cancelled); + assert_eq!(polls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(selector.calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); +} diff --git a/docs/architecture.md b/docs/architecture.md index 69ad22545..9e0dcaa7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,6 +141,11 @@ flowchart LR 正文按需读取,透传无需额外读取权限;转换和改写不能绕过 Core 的交付、取消与结算边界。 数据面插件通过受管 HTTP 已发送或无法证明未发送时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 Provider 的 `not_sent` 结果透明重放 +- **上游适配器是洋葱链终端**:`upstream_adapter` 只扩展内置 OpenAI / xAI,沿原 Provider 的选号、租约及 attempt 中间件进入冷流。 + Provider 解释凭据、恢复账号状态和计算价格;Runtime 限定目标、绑定账号版本并解析标准事件,Host 负责真实出站。 + 普通 HTTP 与账号 HTTP 共用发送、背压、取消和正文回收,账号鉴权只装饰已授权请求。 + WebSocket 连接续接绑定 Key、账号、凭据版本、实例和代次,不能当作可重排的中间件或全局共享句柄。 + 路由、调度、重试和终态观察保留各自端口,适配器不能另建执行引擎或提交最终账单 - **访问域不跨调用**:账号、HTTP、模型、私有状态和日志回调绑定有效父调用与阶段。管理页和 CLI 不预绑定 Client Key,模型调用按次 选择当前 Key;页面 Responses 桥还复核精确实例目标和 `models` 域,并在等待与交付期间持续撤销检查。 `frontend_authentication` 必须显式配置 principal 到 Key 的映射;公开登录回调使用一次性票据且不继承宿主回调权限。 diff --git a/frontend/src/views/plugins/components/PluginBindingEditor.vue b/frontend/src/views/plugins/components/PluginBindingEditor.vue index af89cf96e..f58548fa5 100644 --- a/frontend/src/views/plugins/components/PluginBindingEditor.vue +++ b/frontend/src/views/plugins/components/PluginBindingEditor.vue @@ -12,6 +12,7 @@ const emit = defineEmits<{ validityChange: [valid: boolean] }>() const bindings = defineModel({ required: true }) const stages: Record = { middleware: ['request', 'attempt'], + upstream_adapter: ['upstream'], model_router: ['routing'], scheduler: ['scheduling'], retry_policy: ['retry'], @@ -19,7 +20,7 @@ const stages: Record = { usage: ['observation'], web_socket_observer: ['observation'], } -const stageLabels: Record = { request: '请求开始', attempt: '每次尝试', routing: '模型路由', scheduling: '账号调度', retry: '重试决策', observation: '请求结束' } +const stageLabels: Record = { request: '请求开始', attempt: '每次尝试', upstream: '上游调用', routing: '模型路由', scheduling: '账号调度', retry: '重试决策', observation: '请求结束' } const entries = computed(() => Object.entries(props.metadata.contributes).flatMap(([capability, contribution]) => contribution.stages.filter(stage => stages[capability]?.includes(stage)) .map(stage => ({ capability, contribution: contribution.id, stage, key: `${contribution.id}:${stage}` })), @@ -98,8 +99,8 @@ 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 49879edb3..09e8a79d4 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', 'retry', 'observation'].includes(stage)))) +const hasRequestBindings = computed(() => Object.values(props.artifact?.metadata.contributes ?? {}).some(contribution => contribution.stages.some(stage => ['request', 'attempt', 'upstream', '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 2b1f2305e..80a2389da 100644 --- a/frontend/src/views/plugins/components/PluginConfigurationSummary.vue +++ b/frontend/src/views/plugins/components/PluginConfigurationSummary.vue @@ -14,6 +14,7 @@ const props = defineProps<{ const stageLabels: Record = { request: '请求开始', attempt: '每次尝试', + upstream: '上游调用', routing: '模型路由', scheduling: '账号调度', retry: '重试决策', @@ -23,7 +24,7 @@ const stageLabels: Record = { maintenance: '后台维护', authentication: '客户端认证', } -const requestStages = new Set(['request', 'attempt', 'routing', 'scheduling', 'retry', 'observation']) +const requestStages = new Set(['request', 'attempt', 'upstream', '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/utils/model.ts b/frontend/src/views/plugins/utils/model.ts index 42d7ca37a..f1d8a6035 100644 --- a/frontend/src/views/plugins/utils/model.ts +++ b/frontend/src/views/plugins/utils/model.ts @@ -41,6 +41,7 @@ export const PLUGIN_CAPABILITY_LABELS: Record = { retry_policy: '重试策略', executor: 'Provider 执行', middleware: '请求中间件', + upstream_adapter: '上游适配器', request_lifecycle: '请求生命周期', web_socket_observer: 'WebSocket 观察', usage: '用量处理',