diff --git a/backend/apps/plugin-cli/tests/main.rs b/backend/apps/plugin-cli/tests/main.rs index ffcab7395..a7903b807 100644 --- a/backend/apps/plugin-cli/tests/main.rs +++ b/backend/apps/plugin-cli/tests/main.rs @@ -250,7 +250,7 @@ fn source_manifest() -> &'static [u8] { "main":"bin/plugin", "runtime":"trustedProcess", "contributes":{ - "middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]}, + "middleware":{"version":6,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]}, "management":{"version":2} }, diff --git a/backend/crates/gateway-core/src/engine/middleware/contract.rs b/backend/crates/gateway-core/src/engine/middleware/contract.rs index 6146083fe..e6479db24 100644 --- a/backend/crates/gateway-core/src/engine/middleware/contract.rs +++ b/backend/crates/gateway-core/src/engine/middleware/contract.rs @@ -43,6 +43,8 @@ pub enum MiddlewareMount { Request, /// 受管 Provider attempt 内层;每次真实 retry 都重新建立 Attempt, + /// Provider 完成序列化后、提交实际发送前的请求准备 + Outbound, } /// 中间件正文块的宿主边界 @@ -102,6 +104,7 @@ impl fmt::Debug for MiddlewareHeader { #[derive(Clone, PartialEq, Eq)] pub struct MiddlewareRequest { settings: Option, + provider_parameters: Option>, protocol: String, headers: Vec, body: Bytes, @@ -123,6 +126,7 @@ impl MiddlewareRequest { let protocol = protocol.into(); Self { settings: None, + provider_parameters: None, original_protocol: protocol.clone(), original_body: body.clone(), capabilities: None, @@ -143,6 +147,27 @@ impl MiddlewareRequest { self.settings.as_ref() } + /// Provider 提供的本次构造参数;Core 不解释其中的业务字段 + #[must_use] + pub fn provider_parameters(&self) -> Option<&serde_json::Map> { + self.provider_parameters.as_ref() + } + + #[must_use] + pub fn with_provider_parameters( + mut self, + parameters: serde_json::Map, + ) -> Self { + self.provider_parameters = Some(parameters); + self + } + + pub fn take_provider_parameters( + &mut self, + ) -> Option> { + self.provider_parameters.take() + } + #[must_use] pub fn protocol(&self) -> &str { &self.protocol diff --git a/backend/crates/gateway-core/src/engine/middleware/mod.rs b/backend/crates/gateway-core/src/engine/middleware/mod.rs index c193773fa..8bf7ed70d 100644 --- a/backend/crates/gateway-core/src/engine/middleware/mod.rs +++ b/backend/crates/gateway-core/src/engine/middleware/mod.rs @@ -2,6 +2,7 @@ mod contract; pub mod http; +pub mod outbound; mod plan; pub mod service; pub mod websocket; diff --git a/backend/crates/gateway-core/src/engine/middleware/outbound.rs b/backend/crates/gateway-core/src/engine/middleware/outbound.rs new file mode 100644 index 000000000..f70792432 --- /dev/null +++ b/backend/crates/gateway-core/src/engine/middleware/outbound.rs @@ -0,0 +1,140 @@ +//! Provider 发送前的完整请求准备;续体只组合值,不执行网络操作 + +use std::fmt; + +use bytes::Bytes; +use futures::FutureExt; + +use super::{FrozenMiddlewarePlan, MiddlewareContext, MiddlewareError, MiddlewareHeader}; +use crate::error::{ProviderError, ProviderErrorKind}; +use crate::upstream::UpstreamSendState; + +/// 当前发送所用的传输类型与目标;准备过程中不能切换传输类型 +#[derive(Clone, PartialEq, Eq)] +pub enum Target { + Http { + method: http::Method, + uri: http::Uri, + }, + WebSocket { + uri: http::Uri, + }, +} + +impl fmt::Debug for Target { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http { method, .. } => formatter + .debug_struct("Http") + .field("method", method) + .finish_non_exhaustive(), + Self::WebSocket { .. } => formatter.debug_struct("WebSocket").finish_non_exhaustive(), + } + } +} + +/// HTTP 正文已完成序列化与内容编码;WebSocket 正文是一条完整文本消息 +/// +/// WebSocket headers 表示候选 opening,不能改写已经建立的连接握手 +#[derive(Clone, PartialEq, Eq)] +pub struct Request { + pub target: Target, + pub headers: Vec, + pub body: Bytes, +} + +impl Request { + /// 转换实际发送的 HTTP 头,保留多值与原始字节,不修复插件输出 + pub fn header_map(&self) -> Result { + let mut headers = http::HeaderMap::try_with_capacity(self.headers.len()) + .map_err(|_| preparation_error(ProviderErrorKind::InvalidRequest))?; + for header in &self.headers { + let name = http::HeaderName::from_bytes(header.name().as_bytes()) + .map_err(|_| preparation_error(ProviderErrorKind::InvalidRequest))?; + let value = http::HeaderValue::from_bytes(header.value()) + .map_err(|_| preparation_error(ProviderErrorKind::InvalidRequest))?; + headers + .try_append(name, value) + .map_err(|_| preparation_error(ProviderErrorKind::InvalidRequest))?; + } + Ok(headers) + } +} + +impl fmt::Debug for Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Request") + .field("target", &self.target) + .field("headers", &self.headers.len()) + .field("body_bytes", &self.body.len()) + .finish() + } +} + +pub type Next = crate::middleware::Next; + +/// 保活本次 attempt 的冻结发布代次、归属和资源上下文 +#[derive(Clone, Debug)] +pub struct Pipeline { + plan: FrozenMiddlewarePlan, + context: MiddlewareContext, +} + +impl Pipeline { + pub(in crate::engine) const fn new( + plan: FrozenMiddlewarePlan, + context: MiddlewareContext, + ) -> Self { + Self { plan, context } + } + + /// 完整返回洋葱链最终输出后,Provider 才能提交实际发送 + pub async fn prepare(&self, request: Request) -> Result { + if self.context.cancellation().is_cancelled() { + return Err(preparation_error(ProviderErrorKind::Cancelled)); + } + if self.context.deadline().is_elapsed() { + return Err(preparation_error(ProviderErrorKind::Timeout)); + } + let target_kind = std::mem::discriminant(&request.target); + let next = + crate::middleware::compose(Vec::new(), |request| Box::pin(async { Ok(request) })); + let prepare = self + .plan + .handle_outbound(self.context.clone(), request, next) + .fuse(); + let cancelled = self.context.cancellation().cancelled().fuse(); + let expired = self.context.deadline().wait().fuse(); + futures::pin_mut!(prepare, cancelled, expired); + let prepared = futures::select_biased! { + () = cancelled => return Err(preparation_error(ProviderErrorKind::Cancelled)), + () = expired => return Err(preparation_error(ProviderErrorKind::Timeout)), + result = prepare => result.map_err(|error| { + crate::engine::provider::middleware_error(error, UpstreamSendState::NotSent) + .with_retry_prohibited() + })?, + }; + if self.context.cancellation().is_cancelled() { + return Err(preparation_error(ProviderErrorKind::Cancelled)); + } + if self.context.deadline().is_elapsed() { + return Err(preparation_error(ProviderErrorKind::Timeout)); + } + if std::mem::discriminant(&prepared.target) != target_kind + || self + .context + .request_body_limit() + .is_some_and(|limit| prepared.body.len() > limit) + { + return Err(preparation_error(ProviderErrorKind::InvalidRequest)); + } + Ok(prepared) + } +} + +fn preparation_error(kind: ProviderErrorKind) -> ProviderError { + ProviderError::new(kind, UpstreamSendState::NotSent) + .with_retry_prohibited() + .redact_sensitive_context("invalid outbound request preparation") +} diff --git a/backend/crates/gateway-core/src/engine/middleware/plan.rs b/backend/crates/gateway-core/src/engine/middleware/plan.rs index d96ab8f86..96f08e63a 100644 --- a/backend/crates/gateway-core/src/engine/middleware/plan.rs +++ b/backend/crates/gateway-core/src/engine/middleware/plan.rs @@ -9,6 +9,18 @@ use std::{fmt, sync::Arc}; /// 一个发布代次冻结的统一数据面中间件计划 pub trait MiddlewarePlan: Send + Sync + fmt::Debug { + fn has_outbound(&self, _context: &MiddlewareContext) -> bool { + false + } + fn handle_outbound( + &self, + _context: MiddlewareContext, + request: super::outbound::Request, + next: super::outbound::Next, + ) -> BoxFuture<'static, Result> { + next.run(request) + } + fn has_service(&self) -> bool { false } @@ -73,6 +85,20 @@ pub struct FrozenMiddlewarePlan { } impl FrozenMiddlewarePlan { + #[must_use] + pub fn has_outbound(&self, context: &MiddlewareContext) -> bool { + self.plan.has_outbound(context) + } + pub fn handle_outbound( + &self, + mut context: MiddlewareContext, + request: super::outbound::Request, + next: super::outbound::Next, + ) -> BoxFuture<'static, Result> { + context.plan = Some(self.clone()); + self.plan.handle_outbound(context, request, next) + } + #[must_use] pub fn has_service(&self) -> bool { self.plan.has_service() diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index d338986bf..e713c9e3a 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -755,6 +755,7 @@ impl AttemptContext { provider: ProviderKind, model: Option, account_id: ProviderAccountId, + parameters: Option>, terminal: provider::ProviderMiddlewareTerminal, ) -> Result { // 入口已校验原请求,此处复查中间件最终提交的 operation,每次重试均生效 @@ -762,7 +763,7 @@ impl AttemptContext { .account_scope() .is_some_and(|scope| !scope.allows_image_generation()) { - Box::new(move |operation, headers| { + Box::new(move |operation, headers, parameters| { Box::pin(async move { image_generation::check_image_generation(false, &operation).map_err(|_| { ProviderError::new( @@ -771,18 +772,64 @@ impl AttemptContext { ) .with_retry_prohibited() })?; - terminal(operation, headers).await + terminal(operation, headers, parameters).await }) }) } else { terminal }; - let context = middleware::MiddlewareContext::new( + let context = self.middleware_context( + middleware::MiddlewareMount::Attempt, + Some(operation.kind()), + provider, + model, + account_id, + ); + provider::execute_attempt_middleware( + self.request.middleware.as_ref(), + context, + operation, + self.request.client_transport, + parameters, + terminal, + ) + .await + } + + /// 为当前实际账号冻结出站准备链,无此阶段时保留 Provider 原生路径 + #[must_use] + pub fn outbound_middleware( + &self, + provider: ProviderKind, + model: Option, + account_id: ProviderAccountId, + ) -> Option { + let plan = self.request.middleware.as_ref()?; + let context = self.middleware_context( + middleware::MiddlewareMount::Outbound, + None, + provider, + model, + account_id, + ); + plan.has_outbound(&context) + .then(|| middleware::outbound::Pipeline::new(plan.clone(), context)) + } + + fn middleware_context( + &self, + mount: middleware::MiddlewareMount, + operation: Option, + provider: ProviderKind, + model: Option, + account_id: ProviderAccountId, + ) -> middleware::MiddlewareContext { + middleware::MiddlewareContext::new( middleware::MiddlewareTarget { request_id: self.request.request_id.clone(), - mount: middleware::MiddlewareMount::Attempt, + mount, attempt_index: Some(self.attempt_index), - operation: Some(operation.kind()), + operation, endpoint: self.request.endpoint.clone(), transport: self.request.client_transport, provider: Some(provider), @@ -798,15 +845,7 @@ impl AttemptContext { execution_effects: self.request.execution_effects.as_ref().map(Arc::clone), }, ) - .with_request_body_limit(self.request.request_body_limit); - provider::execute_attempt_middleware( - self.request.middleware.as_ref(), - context, - operation, - self.request.client_transport, - terminal, - ) - .await + .with_request_body_limit(self.request.request_body_limit) } #[must_use] diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index f3dcf084a..1ae57ef58 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -471,6 +471,7 @@ pub type ProviderMiddlewareTerminal = Box< dyn FnOnce( Operation, Vec, + Option>, ) -> BoxFuture<'static, Result> + Send + 'static, @@ -489,18 +490,19 @@ fn provider_middleware_next( activate: oneshot::Receiver<()>, observation: Arc>, ) -> MiddlewareNext { - crate::middleware::compose(Vec::new(), move |request| { + crate::middleware::compose(Vec::new(), move |mut request| { Box::pin(async move { let fallback_protocol = operation.protocol().to_owned(); if request.has_capability_declaration() { return Err(MiddlewareError::InvalidState); } + let parameters = request.take_provider_parameters(); let (protocol, headers, body) = request.into_parts(); validate_attempt_headers(&headers)?; let operation = operation .replace_middleware_wire(protocol, body) .map_err(|_| MiddlewareError::InvalidState)?; - let stream = terminal(operation, headers) + let stream = terminal(operation, headers, parameters) .await .map_err(MiddlewareError::Provider)?; let metadata = stream.metadata().clone(); @@ -734,18 +736,22 @@ pub async fn execute_attempt_middleware( context: MiddlewareContext, operation: Operation, transport: ClientTransport, + parameters: Option>, terminal: ProviderMiddlewareTerminal, ) -> Result { let Some(plan) = plan else { - return terminal(operation, Vec::new()).await; + return terminal(operation, Vec::new(), parameters).await; }; - let request = MiddlewareRequest::new( + let mut request = MiddlewareRequest::new( operation.protocol(), Vec::::new(), operation .middleware_body() .map_err(|_| middleware_protocol_error(UpstreamSendState::NotSent))?, ); + if let Some(parameters) = parameters { + request = request.with_provider_parameters(parameters); + } let (prepared, metadata) = oneshot::channel(); let (activate, activation) = oneshot::channel(); let observation = Arc::new(Mutex::new(MiddlewareSendObservation { @@ -893,7 +899,10 @@ fn unprepared_middleware_response( } } -fn middleware_error(error: MiddlewareError, send_state: UpstreamSendState) -> ProviderError { +pub(super) fn middleware_error( + error: MiddlewareError, + send_state: UpstreamSendState, +) -> ProviderError { match error { MiddlewareError::Provider(error) => error, MiddlewareError::Rejected | MiddlewareError::Remote { rejected: true, .. } => { diff --git a/backend/crates/gateway-core/tests/engine/execution/mod.rs b/backend/crates/gateway-core/tests/engine/execution/mod.rs index c44c83a65..e860704cf 100644 --- a/backend/crates/gateway-core/tests/engine/execution/mod.rs +++ b/backend/crates/gateway-core/tests/engine/execution/mod.rs @@ -1,4 +1,6 @@ //! 执行服务的准入、预算、请求记录与终结行为测试 + +mod outbound; use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}; use std::{ diff --git a/backend/crates/gateway-core/tests/engine/execution/outbound.rs b/backend/crates/gateway-core/tests/engine/execution/outbound.rs new file mode 100644 index 000000000..c4d5449cd --- /dev/null +++ b/backend/crates/gateway-core/tests/engine/execution/outbound.rs @@ -0,0 +1,224 @@ +//! 出站插件的外部副作用在错误改写后仍约束 Engine 重放 + +use super::*; +use gateway_core::engine::extensions::{ExecutionExtensionIndex, ExecutionExtensionPlans}; +use gateway_core::engine::middleware::{ + MiddlewareContext, MiddlewareError, MiddlewareMount, MiddlewareNext, MiddlewarePlan, + MiddlewareRequest, MiddlewareResponse, outbound, +}; + +#[derive(Debug)] +struct EffectfulOutbound { + catch_error: bool, + caught: Arc, +} + +impl MiddlewarePlan for EffectfulOutbound { + fn has_outbound(&self, _context: &MiddlewareContext) -> bool { + true + } + + fn handle_outbound( + &self, + context: MiddlewareContext, + _: outbound::Request, + _: outbound::Next, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + // 对应 Runtime 确认受管 HTTP 已发送后的唯一记录入口 + context.execution_effects().unwrap().observe(); + Err(MiddlewareError::Fault) + }) + } + + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + let catch_error = self.catch_error && context.mount() == MiddlewareMount::Attempt; + if !catch_error { + return next.run(request); + } + let caught = Arc::clone(&self.caught); + Box::pin(async move { + let response = next.run(request).await?; + let (_, _, _, mut body, _) = response.into_parts(); + body.next_frame() + .await + .expect_err("outbound preparation fails before a frame"); + body.close().await; + caught.fetch_add(1, Ordering::SeqCst); + Err(MiddlewareError::Provider(ProviderError::new( + ProviderErrorKind::Transport, + UpstreamSendState::NotSent, + ))) + }) + } +} + +struct PreparingProvider { + calls: AtomicUsize, +} + +#[async_trait] +impl Provider for PreparingProvider { + fn name(&self) -> &'static str { + "openai" + } + fn catalog_generation(&self) -> ProviderCatalogGeneration { + ProviderCatalogGeneration::default() + } + async fn query_model_capabilities( + &self, + ) -> Result, ProviderError> { + Ok(vec![]) + } + + async fn execute( + self: Arc, + request: ProviderRequest, + context: AttemptContext, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let provider = request.candidate().provider().clone(); + let model = request + .candidate() + .upstream_model() + .map(|model| model.as_str().to_owned()); + let account = ProviderAccountId::new("acct_openai").unwrap(); + let pipeline = context + .outbound_middleware(provider.clone(), model.clone(), account.clone()) + .unwrap(); + let metadata = ProviderCallMetadata::new( + provider.clone(), + request.candidate().upstream_model().unwrap().clone(), + account.clone(), + UpstreamTransport::new("http").unwrap(), + ); + context + .execute_middleware( + request.operation().clone(), + provider, + model, + account, + None, + Box::new(move |_, _, _| { + Box::pin(async move { + let events = futures::stream::once(async move { + Err::( + pipeline + .prepare(outbound::Request { + target: outbound::Target::Http { + method: http::Method::POST, + uri: "https://example.test/".parse().unwrap(), + }, + headers: Vec::new(), + body: bytes::Bytes::from_static(b"request"), + }) + .await + .unwrap_err(), + ) + }); + Ok(ProviderStream::new(metadata, events, ())) + }) + }), + ) + .await + } +} + +struct Fixture { + service: DefaultExecutionService, + provider: Arc, + observer: Arc, + caught: Arc, + _extensions: Arc, +} + +fn fixture(catch_error: bool) -> Fixture { + let provider = Arc::new(PreparingProvider { + calls: AtomicUsize::new(0), + }); + let generation = super::super::extensions::reference("outbound-effect"); + let extension_index = ExecutionExtensionIndex::default(); + let caught = Arc::new(AtomicUsize::new(0)); + let extension_owner = extension_index + .register( + generation.id().clone(), + Arc::new(ExecutionExtensionPlans { + middleware: Some(Arc::new(EffectfulOutbound { + catch_error, + caught: Arc::clone(&caught), + })), + upstream_adapters: None, + }), + ) + .unwrap(); + let observer_index = RequestObserverExtensionIndex::default(); + let observer = Arc::new(RecordingRequestObserver::default()); + let _observer_owner = observer_index + .register(generation.id().clone(), observer.clone()) + .unwrap(); + let providers = ProviderRegistry::new([provider.clone() as Arc]).unwrap(); + let service = DefaultExecutionService::new( + RuntimeSnapshotHandle::new(fallback_observation_snapshot(generation)), + Arc::new(TrackingExecutionStore::default()), + providers, + Arc::new(Admissions::default()), + Arc::new(UnusedContinuation), + Arc::new(RecordingClientApiKeyUsage::default()), + Arc::new(crate::RecordingDiagnostics::default()), + ) + .with_execution_extensions(extension_index) + .with_request_observers(observer_index); + Fixture { + service, + provider, + observer, + caught, + _extensions: extension_owner, + } +} + +#[test] +fn outbound_external_effect_is_preserved_when_preparation_fails() { + block_on(async { + let fixture = fixture(false); + let mut started = fixture + .service + .start(request(&fixture.service, ClientTransport::HttpJson)) + .await + .unwrap(); + assert!( + matches!(started.session.collect_uncommitted().await, Err(EngineError::Provider(error)) if error.kind() == ProviderErrorKind::Protocol) + ); + started.session.detach_finalize().await; + assert_eq!(fixture.provider.calls.load(Ordering::SeqCst), 1); + let observations = fixture.observer.observations.lock().unwrap(); + assert_eq!(observations[0].send_state(), UpstreamSendState::Ambiguous); + assert_eq!(observations[0].attempt_count(), 1); + }); +} + +#[test] +fn attempt_cannot_hide_outbound_external_effect_with_retryable_not_sent_error() { + block_on(async { + let fixture = fixture(true); + let mut started = fixture + .service + .start(request(&fixture.service, ClientTransport::HttpJson)) + .await + .unwrap(); + assert!( + matches!(started.session.collect_uncommitted().await, Err(EngineError::Provider(error)) if error.kind() == ProviderErrorKind::Transport && !error.retry_is_prohibited()) + ); + started.session.detach_finalize().await; + assert_eq!(fixture.caught.load(Ordering::SeqCst), 1); + assert_eq!(fixture.provider.calls.load(Ordering::SeqCst), 1); + let observations = fixture.observer.observations.lock().unwrap(); + assert_eq!(observations[0].send_state(), UpstreamSendState::Ambiguous); + assert_eq!(observations[0].attempt_count(), 1); + }); +} diff --git a/backend/crates/gateway-core/tests/engine/middleware/cold.rs b/backend/crates/gateway-core/tests/engine/middleware/cold.rs index 93bb8087b..3015d470d 100644 --- a/backend/crates/gateway-core/tests/engine/middleware/cold.rs +++ b/backend/crates/gateway-core/tests/engine/middleware/cold.rs @@ -88,7 +88,7 @@ fn plan(action: Action) -> FrozenMiddlewarePlan { } fn terminal(polls: Arc, released: Arc) -> ProviderMiddlewareTerminal { - Box::new(move |_, _| { + Box::new(move |_, _, _| { Box::pin(async move { let metadata = ProviderCallMetadata::new( ProviderKind::new("openai").unwrap(), @@ -127,6 +127,7 @@ fn inspecting_a_response_waits_for_the_outer_stream_and_keeps_send_facts() { context(), operation(), ClientTransport::HttpJson, + None, terminal(polls.clone(), released.clone()), ) .await @@ -165,6 +166,7 @@ fn rejecting_metadata_drops_the_unactivated_handler_and_account_lease() { context(), operation(), ClientTransport::HttpJson, + None, terminal(polls.clone(), released.clone()), ) .await @@ -194,7 +196,8 @@ fn middleware_rejection_before_next_does_not_wait_for_activation() { context(), operation(), ClientTransport::HttpJson, - Box::new(|_, _| panic!("rejected middleware cannot prepare upstream")), + None, + Box::new(|_, _, _| panic!("rejected middleware cannot prepare upstream")), ) .await .err() diff --git a/backend/crates/gateway-core/tests/engine/middleware/mod.rs b/backend/crates/gateway-core/tests/engine/middleware/mod.rs index 940eafab6..b86b834fb 100644 --- a/backend/crates/gateway-core/tests/engine/middleware/mod.rs +++ b/backend/crates/gateway-core/tests/engine/middleware/mod.rs @@ -1,6 +1,7 @@ //! 请求中间件的改写、交付状态与发布代次持有测试 mod cold; +mod outbound; use std::num::NonZeroU32; use std::sync::Arc; @@ -339,7 +340,7 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() { let terminal: ProviderMiddlewareTerminal = { let terminal_calls = Arc::clone(&terminal_calls); - Box::new(move |operation, headers| { + Box::new(move |operation, headers, _| { terminal_calls.fetch_add(1, Ordering::SeqCst); Box::pin(async move { assert!(headers.is_empty()); @@ -391,6 +392,7 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() { context(), operation(), ClientTransport::HttpJson, + None, terminal, ) .await @@ -485,7 +487,7 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() { payload.clone(), ) .unwrap(); - let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| { + let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| { Box::pin(async move { Ok(ProviderStream::new( empty_provider_stream().metadata().clone(), @@ -499,6 +501,7 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() { context(), operation(), transport, + None, terminal, ) .await @@ -566,7 +569,7 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() { Some(10), ) .unwrap(); - let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| { + let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| { Box::pin(async move { Ok(ProviderStream::new( empty_provider_stream().metadata().clone(), @@ -581,6 +584,7 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() { context(), operation(), ClientTransport::HttpSse, + None, terminal, ) .await @@ -595,7 +599,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() { let allowed_calls = Arc::new(AtomicUsize::new(0)); let allowed_terminal: ProviderMiddlewareTerminal = { let allowed_calls = Arc::clone(&allowed_calls); - Box::new(move |_operation, headers| { + Box::new(move |_operation, headers, _| { allowed_calls.fetch_add(1, Ordering::SeqCst); Box::pin(async move { assert_eq!(headers.len(), 2); @@ -623,6 +627,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() { context(), operation(), ClientTransport::HttpJson, + None, allowed_terminal, ) .await @@ -632,7 +637,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() { let calls = Arc::new(AtomicUsize::new(0)); let terminal: ProviderMiddlewareTerminal = { let calls = calls.clone(); - Box::new(move |_, headers| { + Box::new(move |_, headers, _| { calls.fetch_add(1, Ordering::SeqCst); Box::pin(async move { assert_eq!(headers[0].name(), "authorization"); @@ -656,6 +661,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() { context(), operation(), ClientTransport::HttpJson, + None, terminal, ) .await @@ -735,7 +741,8 @@ fn attempt_middleware_cannot_add_image_generation_to_a_denied_scope() { ProviderKind::new("openai").unwrap(), Some("gpt-test".into()), ProviderAccountId::new("acct_middleware").unwrap(), - Box::new(move |operation, _| { + None, + Box::new(move |operation, _, _| { Box::pin(async move { assert!(operation.image_generation_requested()); count.fetch_add(1, Ordering::SeqCst); diff --git a/backend/crates/gateway-core/tests/engine/middleware/outbound.rs b/backend/crates/gateway-core/tests/engine/middleware/outbound.rs new file mode 100644 index 000000000..465092c7b --- /dev/null +++ b/backend/crates/gateway-core/tests/engine/middleware/outbound.rs @@ -0,0 +1,303 @@ +//! 出站准备链的返回顺序、预算、生命周期与不可修改归属 + +use super::*; +use gateway_core::account::{AccountSelectionPolicy, RotationStrategy}; +use gateway_core::engine::middleware::outbound::{Pipeline, Request, Target}; +use gateway_core::engine::{AccountAttemptContext, AttemptContext, RequestAttemptContext}; +use gateway_core::error::ProviderErrorKind; +use gateway_core::upstream::UpstreamSendState; + +#[derive(Debug, Clone, Copy)] +enum Action { + Forward, + Rewrite, + Oversize, + SwitchTransport, + Reject, + Pending, + Cancel, +} + +#[derive(Debug)] +struct PreparingPlan { + action: Action, + calls: Arc, +} + +impl MiddlewarePlan for PreparingPlan { + fn has_outbound(&self, context: &MiddlewareContext) -> bool { + context.model() == Some("gpt-test") + } + + fn handle_outbound( + &self, + context: MiddlewareContext, + request: Request, + next: gateway_core::engine::middleware::outbound::Next, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Outbound); + assert_eq!(context.provider().unwrap().as_str(), "openai"); + assert_eq!(context.account_id().unwrap().as_str(), "acct_middleware"); + assert_eq!(context.model(), Some("gpt-test")); + assert_eq!(context.attempt_index(), NonZeroU32::new(2)); + assert_eq!(context.request_id().as_str(), "req_outbound"); + assert_eq!(context.client_key_id().as_str(), "key_outbound"); + assert!(context.plan().is_some()); + self.calls.fetch_add(1, Ordering::SeqCst); + let action = self.action; + Box::pin(async move { + let mut request = next.run(request).await?; + match action { + Action::Forward => (), + Action::Rewrite => { + let mut body = request.body.to_vec(); + body.extend_from_slice(b"-after-next"); + request.body = body.into(); + request + .headers + .push(MiddlewareHeader::new("x-raw", Bytes::from_static(&[0x80]))); + } + Action::Oversize => request.body = Bytes::from_static(b"too large"), + Action::SwitchTransport => { + request.target = Target::WebSocket { + uri: "wss://example.test/".parse().unwrap(), + } + } + Action::Reject => return Err(MiddlewareError::Rejected), + Action::Pending => return futures::future::pending().await, + Action::Cancel => { + context.cancellation().cancel(); + return futures::future::pending().await; + } + } + Ok(request) + }) + } + + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + next.run(request) + } +} + +fn pipeline( + action: Action, + limit: usize, + cancellation: CancellationToken, + deadline: SystemTime, +) -> (Pipeline, Arc) { + let (attempt, calls) = attempt(action, limit, cancellation, deadline); + ( + attempt + .outbound_middleware( + ProviderKind::new("openai").unwrap(), + Some("gpt-test".into()), + ProviderAccountId::new("acct_middleware").unwrap(), + ) + .unwrap(), + calls, + ) +} + +fn attempt( + action: Action, + limit: usize, + cancellation: CancellationToken, + deadline: SystemTime, +) -> (AttemptContext, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let plan = FrozenMiddlewarePlan::new( + Arc::new(PreparingPlan { + action, + calls: Arc::clone(&calls), + }), + ExtensionSetReference::new( + ExtensionSetId::new("outbound-test".into()).unwrap(), + Arc::new(ReadyGeneration), + ), + ); + let request = RequestAttemptContext::new( + ModelRequestId::new("req_outbound").unwrap(), + ClientApiKeyId::new("key_outbound").unwrap(), + ) + .with_request_body_limit(limit) + .with_middleware( + Some(plan), + Arc::from([]), + "/v1/responses".into(), + ClientTransport::HttpJson, + ); + let attempt = AttemptContext::new( + request, + NonZeroU32::new(2).unwrap(), + deadline, + AccountSelectionPolicy::new(RotationStrategy::Smart, NonZeroU32::new(1).unwrap()), + AccountAttemptContext::new(Default::default(), None, None), + None, + cancellation, + ); + (attempt, calls) +} + +fn request() -> Request { + Request { + target: Target::Http { + method: http::Method::POST, + uri: "https://example.test/path?secret=private".parse().unwrap(), + }, + headers: vec![MiddlewareHeader::new("x-raw", Bytes::from_static(b"first"))], + body: Bytes::from_static(&[0, 255, 0, 128]), + } +} + +#[test] +fn outbound_returns_outer_rewrite_after_next_without_decoding_body() { + let (pipeline, calls) = pipeline( + Action::Rewrite, + 128, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let prepared = futures::executor::block_on(pipeline.prepare(request())).unwrap(); + assert_eq!( + prepared.body.as_ref(), + &[ + 0, 255, 0, 128, b'-', b'a', b'f', b't', b'e', b'r', b'-', b'n', b'e', b'x', b't' + ] + ); + let headers = prepared.header_map().unwrap(); + assert_eq!( + headers + .get_all("x-raw") + .iter() + .map(http::HeaderValue::as_bytes) + .collect::>(), + vec![b"first".as_slice(), &[0x80]] + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn outbound_checks_final_body_limit_without_repair_or_retry() { + let (pipeline, _) = pipeline( + Action::Oversize, + 4, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let error = futures::executor::block_on(pipeline.prepare(request())).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); +} + +#[test] +fn outbound_cannot_switch_transport_kind() { + let (pipeline, _) = pipeline( + Action::SwitchTransport, + 128, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let error = futures::executor::block_on(pipeline.prepare(request())).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert!(error.retry_is_prohibited()); +} + +#[test] +fn outbound_rejection_is_terminal_and_not_sent() { + let (pipeline, _) = pipeline( + Action::Reject, + 128, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let error = futures::executor::block_on(pipeline.prepare(request())).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::RequestPolicyDenied); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); +} + +#[test] +fn outbound_cancelled_attempt_never_enters_middleware() { + let cancelled = CancellationToken::new(); + cancelled.cancel(); + let (pipeline, calls) = pipeline( + Action::Forward, + 128, + cancelled, + SystemTime::now() + Duration::from_secs(1), + ); + let error = futures::executor::block_on(pipeline.prepare(request())).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Cancelled); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn outbound_deadline_drops_a_pending_preparation() { + let (pipeline, calls) = pipeline( + Action::Pending, + 128, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let error = futures::executor::block_on(pipeline.prepare(request())).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Timeout); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(error.retry_is_prohibited()); +} + +#[test] +fn outbound_header_conversion_rejects_invalid_bytes_without_retry() { + let mut request = request(); + request.headers.push(MiddlewareHeader::new( + "x-invalid", + Bytes::from_static(b"bad\r\nvalue"), + )); + let error = request.header_map().unwrap_err(); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); +} + +#[test] +fn outbound_debug_does_not_expose_uri_headers_or_body() { + let debug = format!("{:?}", request()); + assert!(!debug.contains("private")); + assert!(!debug.contains("first")); + assert!(!debug.contains("255")); +} + +#[test] +fn outbound_cancellation_stops_an_in_flight_preparation() { + let (pipeline, calls) = pipeline( + Action::Cancel, + 128, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let error = futures::executor::block_on(pipeline.prepare(request())).unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::Cancelled); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(error.retry_is_prohibited()); +} + +#[test] +fn outbound_scope_miss_keeps_the_provider_on_its_native_path() { + let (attempt, calls) = attempt( + Action::Reject, + 0, + CancellationToken::new(), + SystemTime::now() + Duration::from_secs(1), + ); + let pipeline = attempt.outbound_middleware( + ProviderKind::new("openai").unwrap(), + Some("unmatched".into()), + ProviderAccountId::new("acct_middleware").unwrap(), + ); + assert!(pipeline.is_none()); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index 140e18855..86db0d164 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -8,7 +8,7 @@ { "capability": "model_router", "versions": [2] }, { "capability": "model_catalog", "versions": [1] }, { "capability": "retry_policy", "versions": [2] }, - { "capability": "middleware", "versions": [5] }, + { "capability": "middleware", "versions": [6] }, { "capability": "upstream_adapter", "versions": [5] }, { "capability": "observer", "versions": [2] }, { "capability": "command_line", "versions": [1] }, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/execution.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/execution.rs index 3e83810f4..38777c730 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/execution.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/execution.rs @@ -51,6 +51,35 @@ impl PluginMiddlewarePlan { } impl MiddlewarePlan for PluginMiddlewarePlan { + fn has_outbound(&self, context: &MiddlewareContext) -> bool { + self.middleware + .get(&MiddlewareMount::Outbound) + .is_some_and(|entries| entries.iter().any(|entry| outbound_matches(entry, context))) + } + + fn handle_outbound( + &self, + context: MiddlewareContext, + request: gateway_core::engine::middleware::outbound::Request, + next: gateway_core::engine::middleware::outbound::Next, + ) -> BoxFuture< + 'static, + Result, + > { + self.chain( + MiddlewareMount::Outbound, + context, + outbound_matches, + |entry, context, request, next, timeout| { + Box::pin(super::outbound::invoke( + entry, context, request, next, timeout, + )) + }, + next, + ) + .run(request) + } + fn has_service(&self) -> bool { self.middleware.contains_key(&MiddlewareMount::Service) } @@ -154,3 +183,14 @@ impl MiddlewarePlan for PluginMiddlewarePlan { .run(request) } } + +// 存在性检查与实际组合使用同一选择规则,未命中时不进入原生报文准备 +fn outbound_matches(entry: &MiddlewareEntry, context: &MiddlewareContext) -> bool { + !context.extension_scope().contains(&entry.instance_id) + && entry.scope.matches_processing( + context.client_key_id(), + context.account_group_ids(), + context.provider(), + context.model(), + ) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/mod.rs index 6e78bc17c..dc73bda18 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/mod.rs @@ -2,6 +2,7 @@ mod execution; mod http; +mod outbound; mod request; mod service; mod websocket; @@ -56,11 +57,16 @@ pub(crate) fn validate_bindings( } if !matches!( stage, - Stage::Http | Stage::WebSocket | Stage::Service | Stage::Request | Stage::Attempt + Stage::Http + | Stage::WebSocket + | Stage::Service + | Stage::Request + | Stage::Attempt + | Stage::Outbound ) || !stages.insert(stage) { return Err(AdminError::invalid( - "同一插件实例的中间件只能在 http/websocket/service/request/attempt 各绑定一次", + "同一插件实例的中间件只能在 http/websocket/service/request/attempt/outbound 各绑定一次", )); } if matches!(stage, Stage::Http | Stage::WebSocket | Stage::Service) @@ -117,7 +123,7 @@ pub(crate) fn unavailable_entries( .filter(|binding| { matches!( binding.stage.as_str(), - "http" | "websocket" | "service" | "request" | "attempt" + "http" | "websocket" | "service" | "request" | "attempt" | "outbound" ) }) .map(|binding| { @@ -151,6 +157,7 @@ fn compile_entry( "websocket" => MiddlewareMount::WebSocket, "request" => MiddlewareMount::Request, "attempt" => MiddlewareMount::Attempt, + "outbound" => MiddlewareMount::Outbound, _ => return Err(AdminError::invalid("插件中间件阶段无效")), }, invocation, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs new file mode 100644 index 000000000..089d6e452 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/outbound.rs @@ -0,0 +1,99 @@ +//! 在 Provider 发送前组合完整出站请求,并沿用模型数据面的调用资源 + +use std::{sync::Arc, time::Duration}; + +use gateway_admin::model::plugins::instances::PluginFailurePolicy; +use gateway_core::engine::middleware::{MiddlewareContext, MiddlewareError, outbound as core}; +use gateway_plugin_sdk::{ + Stage, + call::middleware::{HANDLE_METHOD, outbound as wire}, +}; + +use super::MiddlewareEntry; +use crate::callback::outbound::{self, Invocation}; + +pub(super) async fn invoke( + entry: Arc, + context: MiddlewareContext, + request: core::Request, + next: core::Next, + timeout: Duration, +) -> Result { + let Some(ports) = &entry.invocation else { + return if entry.failure_policy == PluginFailurePolicy::Delegate { + next.run(request).await + } else { + Err(MiddlewareError::Fault) + }; + }; + let invocation = Invocation::new(next); + let input = wire::CallHead { + request: outbound::head(&request), + metadata: wire::Metadata { + request_id: context.request_id().as_str().to_owned(), + client_key_id: context.client_key_id().as_str().to_owned(), + account_group_ids: context + .account_group_ids() + .iter() + .map(|id| id.as_str().to_owned()) + .collect(), + provider: context + .provider() + .ok_or(MiddlewareError::InvalidState)? + .as_str() + .to_owned(), + model: context.model().map(str::to_owned), + account_id: context + .account_id() + .ok_or(MiddlewareError::InvalidState)? + .as_str() + .to_owned(), + attempt_index: context + .attempt_index() + .ok_or(MiddlewareError::InvalidState)? + .get(), + }, + }; + let mut call = ports.session.context(Stage::Outbound, timeout); + call.request = crate::adapter::request_context(context.extension_scope().request()); + call.request_id = Some(input.metadata.request_id.clone()); + call.attempt_id = Some(format!( + "{}:{}", + input.metadata.request_id, input.metadata.attempt_index + )); + call.account_id = Some(input.metadata.account_id.clone()); + let scope = ports + .callbacks + .prepare_data_plane( + &call, + context.execution_effects(), + context.extension_scope().clone(), + context.plan(), + context.cancellation().clone(), + context.request_body_limit(), + ) + .map_err(|_| MiddlewareError::Fault)?; + let _binding = + ports + .callbacks + .bind_middleware(call.resource_scope_id.clone(), invocation, scope)?; + let reply = tokio::select! { + biased; + () = context.cancellation().cancelled() => return Err(MiddlewareError::Fault), + () = context.deadline().wait() => return Err(gateway_core::error::GatewayError::new( + gateway_core::error::GatewayErrorKind::Timeout, + "request deadline elapsed", + ).into()), + reply = ports.session.call( + HANDLE_METHOD, + call, + serde_json::to_value(input).map_err(|_| MiddlewareError::InvalidState)?, + request.body.into(), + ) => reply.map_err(crate::callback::error::rpc_middleware)?, + }; + let head = ports + .session + .decode_response(Stage::Outbound, reply.result) + .map_err(|_| MiddlewareError::InvalidState)?; + outbound::decode(head, reply.payload).map_err(|_| MiddlewareError::InvalidState) +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs index f8fb59254..58af1b191 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs @@ -61,7 +61,10 @@ pub(super) async fn invoke( }; let mut call_context = invocation_ports.session.context( match entry.mount { - MiddlewareMount::Http | MiddlewareMount::WebSocket | MiddlewareMount::Service => { + MiddlewareMount::Http + | MiddlewareMount::WebSocket + | MiddlewareMount::Service + | MiddlewareMount::Outbound => { return Err(MiddlewareError::InvalidState); } MiddlewareMount::Request => Stage::Request, @@ -95,6 +98,7 @@ pub(super) async fn invoke( scope, )?; let head = MiddlewareRequestHead { + provider_parameters: invocation.provider_parameters(), settings_sources: invocation.settings_sources(), settings: invocation .settings() @@ -107,7 +111,10 @@ pub(super) async fn invoke( .collect(), request_id: context.request_id().as_str().to_owned(), mount: match context.mount() { - MiddlewareMount::Http | MiddlewareMount::WebSocket | MiddlewareMount::Service => { + MiddlewareMount::Http + | MiddlewareMount::WebSocket + | MiddlewareMount::Service + | MiddlewareMount::Outbound => { return Err(MiddlewareError::InvalidState); } MiddlewareMount::Request => WireMiddlewareMount::Request, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs index d3792438d..a2b37e43b 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/middleware/mod.rs @@ -135,6 +135,15 @@ pub(crate) enum MiddlewareCompletionBody { } impl MiddlewareInvocation { + pub(crate) fn provider_parameters(&self) -> Option> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .original_request + .provider_parameters() + .cloned() + } + pub(crate) fn settings(&self) -> Result { let state = self .state @@ -334,6 +343,12 @@ impl MiddlewareInvocation { request: MiddlewareNextRequest, payload: Vec, ) -> Result { + if let Some(parameters) = request.provider_parameters { + if original.provider_parameters().is_none() { + return Err(invalid()); + } + original = original.with_provider_parameters(parameters); + } if let Some(settings) = request.settings { let current = original.settings().ok_or_else(|| { PluginFault::new( diff --git a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs index 2359e5c89..6b2f8f6e4 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs @@ -14,6 +14,7 @@ mod keys; mod log; mod middleware; mod model; +pub(crate) mod outbound; pub(crate) mod private_state; pub(crate) mod request_limits; mod resources; @@ -197,6 +198,7 @@ impl PluginCallbacks { context.stage, gateway_plugin_sdk::Stage::Request | gateway_plugin_sdk::Stage::Attempt + | gateway_plugin_sdk::Stage::Outbound | gateway_plugin_sdk::Stage::Routing | gateway_plugin_sdk::Stage::Scheduling | gateway_plugin_sdk::Stage::Retry diff --git a/backend/crates/gateway-plugin/runtime/src/callback/outbound.rs b/backend/crates/gateway-plugin/runtime/src/callback/outbound.rs new file mode 100644 index 000000000..b49163a5e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/outbound.rs @@ -0,0 +1,98 @@ +//! 出站准备的单次续体与完整请求编解码,不发起网络请求 + +use std::sync::{Arc, Mutex}; + +use futures::future::BoxFuture; +use gateway_core::engine::middleware::{MiddlewareHeader, outbound as core}; +use gateway_plugin_sdk::{ + PluginFault, + call::middleware::{MiddlewareHeader as WireHeader, NEXT_METHOD, outbound as wire}, +}; + +use super::{MiddlewareCallback, invalid}; +use crate::RpcReply; + +pub(crate) struct Invocation(Mutex>); + +impl Invocation { + pub(crate) fn new(next: core::Next) -> Arc { + Arc::new(Self(Mutex::new(Some(next)))) + } +} + +impl MiddlewareCallback for Invocation { + fn invoke( + self: Arc, + method: String, + params: serde_json::Value, + payload: Vec, + _maximum_payload: usize, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + if method != NEXT_METHOD { + return Err(invalid()); + } + let request_head = serde_json::from_value(params).map_err(|_| invalid())?; + let request = decode(request_head, payload)?; + let next = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(invalid)?; + let request = next + .run(request) + .await + .map_err(|error| super::error::middleware(&error))?; + Ok(RpcReply { + result: serde_json::to_value(head(&request)).map_err(|_| invalid())?, + payload: request.body.into(), + }) + }) + } +} + +pub(crate) fn head(request: &core::Request) -> wire::RequestHead { + wire::RequestHead { + target: match &request.target { + core::Target::Http { method, uri } => wire::Target::Http { + method: method.to_string(), + uri: uri.to_string(), + }, + core::Target::WebSocket { uri } => wire::Target::WebSocket { + uri: uri.to_string(), + }, + }, + headers: request + .headers + .iter() + .map(|header| WireHeader { + name: header.name().to_owned(), + value: header.value().to_vec(), + }) + .collect(), + } +} + +pub(crate) fn decode( + head: wire::RequestHead, + payload: Vec, +) -> Result { + Ok(core::Request { + target: match head.target { + wire::Target::Http { method, uri } => core::Target::Http { + method: method.parse().map_err(|_| invalid())?, + uri: uri.parse().map_err(|_| invalid())?, + }, + wire::Target::WebSocket { uri } => core::Target::WebSocket { + uri: uri.parse().map_err(|_| invalid())?, + }, + }, + headers: head + .headers + .into_iter() + .map(|header| MiddlewareHeader::new(header.name, header.value.into())) + .collect(), + body: payload.into(), + }) +} diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index c3b9e03ea..78c40ca3f 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -104,6 +104,7 @@ impl RpcError { Stage::WebSocket => "WebSocket 中间件:插件返回了无效响应", Stage::Request => "请求中间件:插件返回了无效响应", Stage::Attempt => "尝试中间件:插件返回了无效响应", + Stage::Outbound => "出站准备中间件:插件返回了无效响应", Stage::Upstream => "上游适配阶段:插件返回了无效响应", Stage::Observation => "观察阶段:插件返回了无效响应", Stage::Management | Stage::PublicManagement => "管理阶段:插件返回了无效响应", diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs index 5c9366196..3db3774c1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs @@ -71,7 +71,8 @@ async fn reading_websocket_facts_preserves_original_text_and_opaque_messages() { attempt_middleware_context_for_transport(Arc::default(), ClientTransport::WebSocket), operation(), ClientTransport::WebSocket, - Box::new(move |_, _| { + None, + Box::new(move |_, _, _| { Box::pin(async move { Ok(ProviderStream::new( ProviderCallMetadata::new( @@ -227,7 +228,8 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut attempt_middleware_context(Arc::default()), operation(), ClientTransport::HttpJson, - Box::new(move |_, _| { + None, + Box::new(move |_, _, _| { Box::pin(async move { Ok(ProviderStream::new( metadata, diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/lifecycle.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/lifecycle.rs index c8f49d63f..37f8e3a31 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/lifecycle.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/lifecycle.rs @@ -3,6 +3,72 @@ use super::*; use gateway_admin::ports::plugins::PluginPreparation; +#[tokio::test] +async fn provider_parameters_compose_through_sdk_without_merging_deleted_fields() { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Attempt], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let original = serde_json::json!({"installation_id":"account-default", "future":{"keep":true}}); + let first = serde_json::json!({"installation_id":"plugin-first"}); + let last = serde_json::json!({}); + let instances = [ + ("parameters-first", original.clone(), first.clone()), + ("parameters-last", first, serde_json::Value::Null), + ] + .into_iter() + .enumerate() + .map(|(index, (id, expected, parameters))| InstanceFixture { + id, + configuration: serde_json::json!({"mode":"parameters", "attempt":true, + "expected_parameters":expected, "parameters":parameters}), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "attempt", + index as i32, + PluginFailurePolicy::Reject, + )], + }) + .collect(); + let (cache, runtime) = setup_package(instances, package).await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + let downstream = Downstream { + calls: Arc::default(), + closes: Arc::default(), + reads: Arc::default(), + error: false, + }; + let response = plan + .handle( + attempt_middleware_context(Arc::default()), + budget_request().with_provider_parameters(original.as_object().unwrap().clone()), + gateway_core::middleware::compose(Vec::new(), move |request| { + assert_eq!( + request.provider_parameters().unwrap(), + last.as_object().unwrap() + ); + downstream.into_next().run(request) + }), + ) + .await + .unwrap(); + let (_, _, _, body, _) = response.into_parts(); + body.close().await; + drop(plan); + drop(generation); + super::super::wait_until_empty(cache.path()).await; +} + #[tokio::test] async fn partial_update_preserves_inflight_middleware_and_uses_new_configuration_for_new_calls() { let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs index 76528d043..14e752949 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs @@ -5,6 +5,7 @@ mod facts; mod http; mod lifecycle; mod mounts; +mod outbound; mod retry; mod scheduling; mod service; @@ -1308,7 +1309,7 @@ async fn real_request_plugins_rewrite_settings_in_onion_order() { InstanceFixture { id: "settings-first", configuration: serde_json::json!({"mode":"settings","expected_settings":baseline_json,"settings":first}), bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",0,PluginFailurePolicy::Reject)] }, InstanceFixture { id: "settings-second", configuration: serde_json::json!({"mode":"settings","expected_settings":first,"settings":last}), bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",1,PluginFailurePolicy::Reject)] }, InstanceFixture { id: "settings-pass", configuration: serde_json::json!({"mode":"settings","expected_settings":last}), bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",2,PluginFailurePolicy::Reject)] }, - ], package).await; + ], package.clone()).await; let generation = prepare(&runtime).await; let plan = runtime .execution_registry() @@ -1369,6 +1370,43 @@ async fn real_request_plugins_rewrite_settings_in_onion_order() { drop(plan); drop(generation); super::wait_until_empty(cache.path()).await; + + // 根对象 null 也是显式替换,必须由设置合同拒绝,不能悄悄沿用原配置 + let (cache, runtime) = setup_package(vec![InstanceFixture { + id: "settings-null", + configuration: serde_json::json!({"mode":"settings", "expected_settings":baseline_json, "settings":null}), + bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, "request", 0, PluginFailurePolicy::Reject)], + }], package).await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + assert!( + plan.handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#) + ) + .with_settings(configuration), + (Downstream { + calls: Arc::clone(&calls), + closes: Arc::default(), + reads: Arc::default(), + error: false + }) + .into_next(), + ) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; } #[tokio::test] @@ -1593,7 +1631,7 @@ impl CapabilitiesNext { #[tokio::test] async fn sdk_capability_declarations_require_request_stage() { - for (version, attempt) in [(5, false), (5, true)] { + for (version, attempt) in [(6, false), (6, true)] { let stage = if attempt { Stage::Attempt } else { diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/outbound.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/outbound.rs new file mode 100644 index 000000000..1d16aabe4 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/outbound.rs @@ -0,0 +1,339 @@ +//! 验证真实插件出站准备的组合、资源预算和终止边界 + +use super::*; +use gateway_core::engine::middleware::outbound::{Next, Request, Target}; + +async fn runtime( + instances: Vec, + limits: RpcLimits, +) -> (tempfile::TempDir, PluginRuntime) { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let package = crate::support::package_with_contributions( + &worker, + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Outbound], + vec!["outbound".into()], + vec!["outbound".into()], + )]), + ); + setup_package_with_limits(instances, package, limits).await +} + +fn instance(id: &'static str, order: i32, mut configuration: serde_json::Value) -> InstanceFixture { + configuration["outbound"] = true.into(); + InstanceFixture { + id, + configuration, + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "outbound", + order, + PluginFailurePolicy::Reject, + )], + } +} + +fn context() -> MiddlewareContext { + context_for_provider("openai") +} + +fn context_for_provider(provider: &str) -> MiddlewareContext { + MiddlewareContext::new( + MiddlewareTarget { + request_id: ModelRequestId::new("req_outbound").unwrap(), + mount: MiddlewareMount::Outbound, + attempt_index: Some(NonZeroU32::MIN), + operation: Some(OperationKind::Generate), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpJson, + provider: Some(ProviderKind::new(provider).unwrap()), + model: Some("public-a".into()), + account_id: Some(ProviderAccountId::new("acct_openai").unwrap()), + }, + MiddlewareAuthority { + client_key_id: ClientApiKeyId::new("key-middleware").unwrap(), + account_group_ids: Arc::from([AccountGroupId::new( + "grp_11111111111111111111111111111111", + ) + .unwrap()]), + cancellation: CancellationToken::new(), + deadline: (SystemTime::now() + Duration::from_secs(5)).into(), + extension_scope: ExtensionCallScope::default(), + execution_effects: Some(Arc::default()), + }, + ) +} + +fn request() -> Request { + Request { + target: Target::Http { + method: ::http::Method::POST, + uri: "https://example.test/original".parse().unwrap(), + }, + headers: vec![MiddlewareHeader::new( + "x-delete", + Bytes::from_static(b"original"), + )], + body: Bytes::from_static(&[0, 255]), + } +} + +fn terminal() -> Next { + gateway_core::middleware::compose(Vec::new(), |request| Box::pin(async { Ok(request) })) +} + +#[tokio::test] +async fn outbound_plugins_compose_http_and_websocket_with_full_replacement() { + for websocket in [false, true] { + let records = tempfile::NamedTempFile::new().unwrap(); + let initial_body = if websocket { + b"msg".to_vec() + } else { + vec![0, 255] + }; + let target = if websocket { + serde_json::json!({"kind":"websocket", "uri":"wss://rewritten.test/socket"}) + } else { + serde_json::json!({"kind":"http", "method":"PUT", "uri":"https://rewritten.test/path?opaque=1"}) + }; + let mut outer = instance( + "outer", + 0, + serde_json::json!({ + "records":records.path(), "enter_byte":65, "exit_byte":68, + "expected_body":initial_body, "target":target, + "headers":[{"name":"x-multi","value":[97]}, {"name":"x-multi","value":[255]}], + "second_next":true, + }), + ); + outer.bindings[0].client_key_ids = vec!["key-middleware".into()]; + outer.bindings[0].account_group_ids = vec!["grp_11111111111111111111111111111111".into()]; + outer.bindings[0].provider_ids = vec!["openai".into()]; + outer.bindings[0].models = vec!["public-a".into()]; + let mut inner_body = initial_body.clone(); + inner_body.push(65); + let mut inner = instance( + "inner", + 1, + serde_json::json!({ + "records":records.path(), "enter_byte":66, "exit_byte":67, "expected_body":inner_body, + }), + ); + inner.bindings[0].provider_ids = vec!["openai".into()]; + let mut excluded = instance("excluded", 2, serde_json::json!({"fault":true})); + excluded.bindings[0].provider_ids = vec!["xai".into()]; + let (cache, runtime) = runtime(vec![outer, inner, excluded], RpcLimits::default()).await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + assert!(plan.has_outbound(&context())); + assert!(!plan.has_outbound(&context_for_provider("unmatched"))); + let mut input = request(); + input.body = initial_body.clone().into(); + if websocket { + input.target = Target::WebSocket { + uri: "wss://example.test/socket".parse().unwrap(), + }; + } + let result = plan + .handle_outbound(context(), input, terminal()) + .await + .unwrap(); + let mut expected_body = initial_body; + expected_body.extend([65, 66, 67, 68]); + assert_eq!(result.body.as_ref(), expected_body); + let headers = result.header_map().unwrap(); + assert!(!headers.contains_key("x-delete")); + assert_eq!( + headers + .get_all("x-multi") + .iter() + .map(|value| value.as_bytes()) + .collect::>(), + [b"a".as_slice(), &[255]] + ); + match result.target { + Target::Http { method, uri } => { + assert_eq!(method, ::http::Method::PUT); + assert_eq!(uri.to_string(), "https://rewritten.test/path?opaque=1"); + } + Target::WebSocket { uri } => assert_eq!(uri.to_string(), "wss://rewritten.test/socket"), + } + assert_eq!( + std::fs::read_to_string(records.path()).unwrap(), + "enter:outer\nenter:inner\nexit:inner\nexit:outer\n" + ); + drop(plan); + drop(generation); + super::super::wait_until_empty(cache.path()).await; + } +} + +#[tokio::test] +async fn outbound_plugin_can_return_directly_without_running_next() { + let (cache, runtime) = runtime( + vec![instance( + "direct", + 0, + serde_json::json!({"direct":true,"exit_byte":42}), + )], + RpcLimits::default(), + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + let result = plan + .handle_outbound( + context(), + request(), + gateway_core::middleware::compose(Vec::new(), |_| { + panic!("direct preparation must not consume next") + }), + ) + .await + .unwrap(); + assert_eq!(result.body.as_ref(), [0, 255, 42]); + drop(plan); + drop(generation); + super::super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn outbound_http_callbacks_use_the_frozen_body_budget_and_propagate_failure() { + for body_len in [3, 4] { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(204)) + .expect(if body_len == 3 { 1 } else { 0 }) + .mount(&server) + .await; + let mut entry = instance( + "callback", + 0, + serde_json::json!({"http_url":server.uri(),"http_body_len":body_len,"fault":true}), + ); + entry.bindings[0].failure_policy = PluginFailurePolicy::Delegate; + let (cache, runtime) = runtime(vec![entry], RpcLimits::default()).await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + let result = plan + .handle_outbound( + context().with_request_body_limit(Some(3)), + request(), + gateway_core::middleware::compose(Vec::new(), |_| { + panic!("plugin failure must not silently replay preparation") + }), + ) + .await; + assert!(result.is_err()); + server.verify().await; + drop(plan); + drop(generation); + super::super::wait_until_empty(cache.path()).await; + } +} + +#[tokio::test] +async fn outbound_invalid_method_is_not_repaired_or_delegated() { + let mut entry = instance( + "invalid", + 0, + serde_json::json!({"direct":true,"target":{"kind":"http","method":"BAD METHOD","uri":"https://example.test/"}}), + ); + entry.bindings[0].failure_policy = PluginFailurePolicy::Delegate; + let (cache, runtime) = runtime(vec![entry], RpcLimits::default()).await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + let result = plan.handle_outbound(context(), request(), terminal()).await; + assert!(matches!(result, Err(MiddlewareError::InvalidState))); + drop(plan); + drop(generation); + super::super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn outbound_next_wait_pauses_plugin_budget_and_cancel_releases_the_continuation() { + for cancel in [false, true] { + let (cache, runtime) = runtime( + vec![ + instance("outer", 0, serde_json::json!({})), + instance("inner", 1, serde_json::json!({})), + ], + RpcLimits { + maximum_call_timeout: Duration::from_millis(300), + ..RpcLimits::default() + }, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime + .execution_registry() + .middleware(&generation) + .unwrap(); + let context = context(); + let cancellation = context.cancellation().clone(); + let (started, entered) = tokio::sync::oneshot::channel(); + let (closed, dropped) = tokio::sync::oneshot::channel(); + let pending = tokio::spawn(async move { + plan.handle_outbound( + context, + request(), + gateway_core::middleware::compose(Vec::new(), move |request| { + Box::pin(async move { + let _closed = NotifyOnDrop(Some(closed)); + started.send(()).unwrap(); + if cancel { + std::future::pending::<()>().await; + } else { + tokio::time::sleep(Duration::from_millis(900)).await; + } + Ok(request) + }) + }), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), entered) + .await + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(!pending.is_finished()); + if cancel { + cancellation.cancel(); + } + let result = tokio::time::timeout(Duration::from_secs(2), pending) + .await + .unwrap() + .unwrap(); + assert_eq!(result.is_err(), cancel); + tokio::time::timeout(Duration::from_secs(2), dropped) + .await + .unwrap() + .unwrap(); + drop(generation); + super::super::wait_until_empty(cache.path()).await; + } +} + +struct NotifyOnDrop(Option>); + +impl Drop for NotifyOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs index a3c955601..6277545b1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -63,7 +63,7 @@ async fn package_inspector_returns_static_requirements_without_starting_the_plug requirements.protocol_version, gateway_plugin_sdk::PROTOCOL_VERSION ); - assert_eq!(requirements.capabilities, vec![("middleware".into(), 5)]); + assert_eq!(requirements.capabilities, vec![("middleware".into(), 6)]); } #[tokio::test] diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs index 946cff815..8d3f74dee 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/middleware/mod.rs @@ -1,6 +1,7 @@ //! 中间件测试插件子进程入口及异常退出清理辅助 mod http_resources; +mod outbound; mod usage_data; use gateway_plugin_sdk::{ @@ -26,7 +27,7 @@ async fn main() -> Result<(), Box> { Capability::Middleware, ContributionDeclaration { id: "test.example.middleware".into(), - version: 5, + version: 6, stages: vec![ Stage::Http, Stage::WebSocket, @@ -88,7 +89,7 @@ async fn main() -> Result<(), Box> { id: "test.example.middleware".into(), version: configuration["middleware_version"] .as_u64() - .unwrap_or(5) + .unwrap_or(6) .try_into()?, stages: vec![if configuration["service"] == true { Stage::Service @@ -98,6 +99,8 @@ async fn main() -> Result<(), Box> { Stage::WebSocket } else if configuration["attempt"] == true { Stage::Attempt + } else if configuration["outbound"] == true { + Stage::Outbound } else { Stage::Request }], @@ -107,6 +110,8 @@ async fn main() -> Result<(), Box> { "http".into() } else if configuration["websocket"] == true { "websocket".into() + } else if configuration["outbound"] == true { + "outbound".into() } else { "openai".into() }], @@ -116,11 +121,23 @@ async fn main() -> Result<(), Box> { "http".into() } else if configuration["websocket"] == true { "websocket".into() + } else if configuration["outbound"] == true { + "outbound".into() } else { "openai".into() }], }, )]); + if configuration["outbound"] == true { + let plugin = MiddlewarePlugin::new( + &contributes, + move |call: gateway_plugin_sdk::client::OutboundCall| { + outbound::handle(call, configuration.clone()) + }, + )?; + session.run(plugin).await?; + return Ok(()); + } if configuration["service"] == true { let plugin = MiddlewarePlugin::new( &contributes, @@ -528,6 +545,19 @@ async fn main() -> Result<(), Box> { call.request.head.settings = settings.clone(); } } + if mode == "parameters" { + assert_eq!( + serde_json::to_value(&call.request.head.provider_parameters).unwrap(), + configuration["expected_parameters"], + ); + if let Some(parameters) = configuration.get("parameters") { + call.request.head.provider_parameters = if parameters.is_null() { + None + } else { + Some(parameters.as_object().unwrap().clone()) + }; + } + } if mode == "inspect" && !call.request.body.is_empty() { // 读取和解析只能作用于副本,不能把解析结果写回未改写的正文 assert!(serde_json::from_slice::(&call.request.body).is_ok()); @@ -590,7 +620,7 @@ async fn main() -> Result<(), Box> { Ok(vec![frame]) })?; } - "passthrough" | "declare" | "settings" => {} + "passthrough" | "declare" | "settings" | "parameters" => {} "inspect" => { response.body = response.body.inspect_frames(|frame| { assert!(!frame.payload.is_empty()); diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware/outbound.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware/outbound.rs new file mode 100644 index 000000000..35580578e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/middleware/outbound.rs @@ -0,0 +1,79 @@ +//! 真实出站准备插件,记录洋葱顺序并测试完整请求与调用资源 + +use gateway_plugin_sdk::{ + ErrorCode, PluginFault, + call::middleware::NEXT_METHOD, + client::{OutboundCall, OutboundRequest, SessionError}, +}; + +pub(super) async fn handle( + mut call: OutboundCall, + configuration: serde_json::Value, +) -> Result { + assert_eq!(call.metadata.provider, "openai"); + assert_eq!(call.metadata.account_id, "acct_openai"); + assert_eq!(call.metadata.attempt_index, 1); + assert_eq!(call.metadata.client_key_id, "key-middleware"); + let record = |phase| { + if let Some(path) = configuration["records"].as_str() { + use std::io::Write as _; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{phase}:{}", call.context.instance_id).unwrap(); + } + }; + record("enter"); + if let Some(expected) = configuration.get("expected_body") { + assert_eq!(serde_json::json!(call.request.body), *expected); + } + if let Some(headers) = configuration.get("headers") { + call.request.head.headers = serde_json::from_value(headers.clone()).unwrap(); + } + if let Some(target) = configuration.get("target") { + call.request.head.target = serde_json::from_value(target.clone()).unwrap(); + } + if let Some(url) = configuration["http_url"].as_str() { + let body_len = configuration["http_body_len"].as_u64().unwrap_or(0) as usize; + call.host + .call( + "host.http.do", + serde_json::json!({"method":"POST", "url":url}), + vec![42; body_len], + ) + .await + .map_err(SessionError::into_plugin_fault)?; + } + if configuration["fault"] == true { + return Err(PluginFault::new(ErrorCode::Fault, "outbound fixture error")); + } + if let Some(byte) = configuration["enter_byte"].as_u64() { + call.request.body.push(byte as u8); + } + let mut request = if configuration["direct"] == true { + call.request + } else { + call.next.run(call.request).await? + }; + if configuration["second_next"] == true { + let result = call + .host + .call( + NEXT_METHOD, + serde_json::to_value(&request.head).unwrap(), + request.body.clone(), + ) + .await; + let Err(error) = result else { + panic!("next can only be consumed once") + }; + assert_eq!(error.into_plugin_fault().code, ErrorCode::InvalidInput); + } + if let Some(byte) = configuration["exit_byte"].as_u64() { + request.body.push(byte as u8); + } + record("exit"); + Ok(request) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs index a1607c334..bad73a66f 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs @@ -1046,6 +1046,7 @@ impl Peer { id, NEXT_METHOD, serde_json::to_value(MiddlewareNextRequest { + provider_parameters: None, settings: None, protocol: None, header_mutations: Vec::new(), diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index 823400ac3..e13317b44 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -18,7 +18,7 @@ SDK 包版本为 `0.1.0`,独立于网关版本,处于实验阶段。能力 | 根门面 | 清单、能力声明、调用上下文、消息与帧、公开错误 | | `call::frontend_authentication` | 数据面认证信封、认证器标识与外部 principal 结果 | | `call::model` | 宿主模型调用的 canonical 事实、原生 wire 与有界事件编解码 | -| `call::middleware` | HTTP、WebSocket 消息、公开服务、模型请求及 attempt 的洋葱调用、单次 next 与惰性正文合同 | +| `call::middleware` | HTTP、WebSocket 消息、公开服务、模型请求、attempt 与上游出站准备的洋葱调用、单次 next 与惰性正文合同 | | `call::services` | 公开服务的类型化操作、输入输出及错误合同;`usage` 提供[用量维护与计价](docs/usage-data.md) | | `call::upstream_adapter` | 内置 OpenAI / xAI 的[受管上游适配器](docs/upstream-adapters.md)注册、执行、出站与续接合同 | | `call::policy` | 模型路由、账号调度与重试决策合同 | @@ -31,7 +31,7 @@ SDK 包版本为 `0.1.0`,独立于网关版本,处于实验阶段。能力 `call/` 按业务能力组织数据,`message.rs` 定义消息与帧。已有请求链的请求/响应改写、协议转换和思考参数映射使用 `call::middleware`;需要指定业务上游并解析结果时使用 `call::upstream_adapter`,作为洋葱链终端复用宿主账号与结算。 -下游 WebSocket 改写使用 `websocket` 中间件;上游 WebSocket 的观察接口保留只读合同 +下游 WebSocket 改写使用 `websocket` 中间件;上游发送前的请求准备使用 `outbound` 中间件,响应观察接口保留只读合同 插件使用的导入路径例如: diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index 3ab4634e2..461517aea 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -216,7 +216,7 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限 `invalid_input`,不启动模型执行。上游适配器的请求 `GPAQ` 也使用请求预算,响应事件仍受 原有 8 MiB 编码上限、Runtime 缓冲预算及流控窗口限制 -HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt、路由、调度、重试和上游适配器 +HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt、outbound、路由、调度、重试和上游适配器 沿用该次模型请求冻结的正文预算。显式 Key 模型调用的预检与执行使用同一绑定快照, 同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新绑定才读取新配置,无需重新启用插件 @@ -245,7 +245,7 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt `host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。 受管 HTTP(包括 `host.upstream.http.do[_stream]`)的请求正文按有效设置 `responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置; -attempt、路由、调度、重试和上游适配器使用执行冻结预算,其余调用读取当前发布配置。 +attempt、outbound、路由、调度、重试和上游适配器使用执行冻结预算,其余调用读取当前发布配置。 超限返回带 HTTP 413 的 `invalid_input` 和 `not_sent`,不向目标发送请求。 注册阶段沿用正在编译的配置预算,首次发布前也可发送非空正文。 非空正文无法取得有效配置时拒绝调用;无正文查询不消耗正文预算。 @@ -258,7 +258,7 @@ attempt、路由、调度、重试和上游适配器使用执行冻结预算, ### 洋葱中间件 -`middleware.version: 5` 的所有挂载使用同一个 `ctx + next` 组合器。清单显式选择处理边界,绑定决定顺序; +`middleware.version: 6` 的所有挂载使用同一个 `ctx + next` 组合器。清单显式选择处理边界,绑定决定顺序; 不按 URL、消息类型或每个内部步骤增加处理器。仅处理一个边界可直接声明其调用类型;跨边界插件使用 `MiddlewareCall` 在一个处理器内匹配类型化视图,不感兴趣的调用交给 `call.forward().await` @@ -269,6 +269,7 @@ attempt、路由、调度、重试和上游适配器使用执行冻结预算, | `service` | `ServiceCall` / `TypedServiceCall` | 已登记的公开业务服务 | | `request` | `RequestCall` | 一次逻辑模型请求 | | `attempt` | `RequestCall` | 本次已选 Provider 和账号的执行 | +| `outbound` | `OutboundCall` | 原样返回准备结果,不发送网络 | `PluginBuilder::middleware` 与 `MiddlewarePlugin::new` 共用 `MiddlewareInput` / `MiddlewareOutput`。 `next.run(input)` 消费自身,最多执行一次;结果按相反顺序经过外层。重试或独立子请求通过宿主执行服务发起 @@ -381,6 +382,45 @@ Ping 的自动 Pong 与关闭握手属于底层 WebSocket 协议事实;插件 重写消息不能撤销已经发生的发送。文本必须是 UTF-8,控制帧长度和关闭格式仍遵守 WebSocket 协议。 每条消息组合有独立 RPC 期限;自定义会话持有一个受管流式调用。连接结束会取消仍在处理的消息与发送 +#### 上游出站准备 + +`outbound` 在 Provider 完成请求序列化后、提交发送前执行;无绑定或未命中范围时保留原生请求构造,不进入出站准备链。 +`OutboundCall.request` 包含 +`head.target`、Provider 已生成的完整 `head.headers` 和二进制 `body`;`target` 为 `Http { method, uri }` +或 `WebSocket { uri }`,不能在准备过程中切换传输类型。HTTP 正文是已经序列化、完成内容编码的实际字节, +插件自行处理压缩与 `Content-Encoding` 等头部的一致性;宿主不解码正文、补回字段或修复请求 + +`OutboundCall.metadata` 提供真实请求、Client Key、分组、Provider、模型、账号与 attempt 序号。 +这些归属由宿主冻结,返回值只有请求;修改 URL、认证头或正文不改变账号租约、凭据版本与计费归属。 +宿主不从最终字节重建模型、选号或续接事实;需要改变这些业务含义时,插件应在相应的 request / attempt 阶段保持一致。 +头部保留重复项与原始字节,正文不经 JSON 编码。 +这些头部是交给 transport 的字段集合;原生 HTTP 客户端 reqwest 会在缺少 `Accept` 时发送 `Accept: */*`,本能力不支持禁用该默认行为。 +最终结果仍须符合原生 transport 的可编码格式与目标约束;xAI 的内置 transport 只接受固定官方推理端点, +当前原生 WebSocket 客户端不接受非 ASCII 握手头值,不可表示的值在发送前报错 + +`next.run(request)` 只进入下层准备链并返回完整请求,不打开连接、不发送消息;外层仍可修改返回值。 +所有外层处理结束后,Provider 才把结果交给 transport 发送。插件错误、非法传输值或正文超限在原生发送前结束, +不自动跳过、重放或切换传输;取消、请求截止与回调资源沿用当前 attempt + +WebSocket 的 `body` 是准备发送的一条完整 UTF-8 文本消息,`headers` 和 `uri` 是候选 opening。 +复用连接时不能改写已发生的握手;宿主不为插件主动重连、重放消息或重建续接。 +插件需要握手身份与消息内容一致时,应结合实际连接与续接语义选择改写 + +```rust +use gateway_plugin_sdk::client::{OutboundCall, PluginBuilder}; + +let plugin = PluginBuilder::from_json(manifest_json)? + .middleware(|call: OutboundCall| async move { + let mut request = call.next.run(call.request).await?; + request.head.headers.retain(|header| header.name != "x-optional"); + Ok(request) + })? + .build()?; +``` + +该处理器的清单声明 `middleware.version: 6` 和 `stages: ["outbound"]`,不需要单独注册网络发送方法。 +当前内置 Provider 接入其模型执行所用的 HTTP 与 WebSocket 文本发送边界;OAuth、额度刷新等后台网络不属于模型出站准备 + #### 模型请求与 attempt `RequestCall.request.head` 包含完整 Client Key 与分组归属。request 中间件改写正文后,OpenAI 在进入 attempt 前应用宿主 Fast 策略; @@ -389,10 +429,22 @@ request 与 attempt 阶段通过 `append_header` 添加的请求头会进入上 OpenAI 与 xAI 的上游 headers 先由账号和画像生成,插件同名字段覆盖基线,同名多值按插件顺序保留;不存在按认证、Cookie 或会话名称划分的保护列表。 实际账号租约、凭据版本与计费归属仍记录真实选中事实,不随插件修改 header 名称而伪造 -middleware v5 的 request 阶段 `request.head.settings` 是本次调用的有效设置对象,包含 `runtime`、`fast_mode`、`client_limits` 和 `timeout_ms`。 +attempt 的 `request.head.provider_parameters` 携带 Provider 提供的本次构造参数;没有该能力的执行不提供此对象。 +OpenAI 原生 Responses、Images、Search 与 Live 创建请求提供 `installation_id` 字符串,默认取自选中账号, +用于原生安装身份、已有 turn metadata 身份字段及会话、缓存关联标识的派生。独立 `upstream_adapter` 自行构造其请求。 + +插件直接修改此对象,SDK 在值改变时提交完整替换;空对象明确删除全部参数,将原本存在的对象设为 `None` 同样提交空对象。 +线协议的 `next.provider_parameters` 省略时保留原值,显式 `null` 或非对象类型拒绝。 +后续插件读取前层结果,Core / Runtime 不解释业务字段、不深合并;缺失必需字段或类型错误在发送前失败且禁止重试,不补回账号默认身份。 + +身份选择、Key 与账号配对、UUID 生成与持久化由插件负责,可复用已有状态服务;参数不写回账号凭据、全局设置或真实计费事实。 +WS 新响应链按构造身份区分连接,精确续接仍使用实际持有该响应链的原连接;Live sideband / hangup 不继承创建请求的临时参数 + +request 阶段 `request.head.settings` 是本次调用的有效设置对象,包含 `runtime`、`fast_mode`、`client_limits` 和 `timeout_ms`。 `fast_mode` 使用 `default`、`enabled`、`disabled` 三态,具体档位语义见[账号分组](../../../../../docs/api.md#6-账号分组)。 `runtime` 使用宿主快照的设置字段,画像已合并 Client Key 设置;可以修改映射、画像、调度、位置及计价等执行参数。 SDK 仅在对象改变时提交完整替换,`false`、`0`、空映射和合同允许的 `null` 不表示跳过;不做隐式深合并。 +线协议省略 `next.settings` 才表示保留;显式 `null` 仍按完整替换校验,因不符合设置对象合同而拒绝。 多个插件按入站顺序读取前层改写后的值,终端在执行前从同一配置代次重算,改写不发布全局配置。 `timeout_ms` 默认为 `null`,表示不限制模型请求总时长;数值从本次模型请求开始计时,`0` 表示立即到期, 改写不会清零已用时间。HTTP 处理期限与 RPC 操作期限仍各自生效 @@ -455,6 +507,7 @@ restore_and_validate_response(response).await | --- | --- | | 请求头/正文改写、提前拒绝、响应头处理 | `middleware` 包裹一次 `next.run(request)` | | 响应逐帧转换、观察 | 返回惰性 `map_frames` / `inspect_frames`,不能在 `next` 返回时就把流视为结束 | +| Provider 已生成请求的最终 URL、头部或字节改写 | `outbound` 返回最终请求,Provider 随后发送 | | 新上游路径、协议编码、错误及用量解析 | `upstream_adapter` 终端,复用已选账号和原生结算 | | 账号代理、默认凭据注入、网络发送状态 | 宿主网络外层,插件可覆盖默认请求头,普通 HTTP 与账号 HTTP 共用发送和正文资源实现 | | 路由、调度、重试决策 | 各自的决策端口,Core 保持最终裁决和预算 | diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index 242f794a0..22d2c8e34 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -53,7 +53,7 @@ RPC v5 的帧布局、编码与会话生命周期见[通信边界](../README.md# | 能力 | 当前版本 | | --- | --- | -| `middleware` | `5` | +| `middleware` | `6` | | `scheduler` | `3` | | `upstream_adapter` | `5` | | `frontend_authentication`、`model_router`、`retry_policy`、`observer`、`management` | `2` | @@ -67,7 +67,7 @@ RPC v5 的帧布局、编码与会话生命周期见[通信边界](../README.md# "management": { "version": 2 }, "command_line": {}, "middleware": { - "version": 5, + "version": 6, "stages": ["request"], "inputFormats": ["openai"], "outputFormats": ["openai"] @@ -90,7 +90,7 @@ capability 固定并由工具生成: `observer` 使用一个处理器接收完成与上游 WebSocket 事件,实例绑定按 `event` 选择订阅类型;具体合同见[观察事件](capabilities.md#路由调度与观察) -`middleware` 必须从 `http`、`websocket`、`service`、`request`、`attempt` 中显式选择挂载;同一处理器可覆盖多个边界,协议格式等真实业务选择也不能省略。 +`middleware` 必须从 `http`、`websocket`、`service`、`request`、`attempt`、`outbound` 中显式选择挂载;同一处理器可覆盖多个边界,协议格式等真实业务选择也不能省略。 安装清单若携带不同的固定阶段会被拒绝,而不是在加载时静默改写 ## 完整信任 diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs index 249449c84..35ed53ff5 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs @@ -1,6 +1,7 @@ //! 统一请求/响应中间件的跨进程数据合同 pub mod http; +pub mod outbound; pub mod websocket; use serde::{Deserialize, Serialize}; @@ -69,6 +70,9 @@ pub struct MiddlewareRequestHead { /// 当前请求的完整有效执行设置;尚未建立可改写设置的边界使用 null #[serde(default)] pub settings: serde_json::Value, + /// 本次 Provider 构造参数;仅原生 attempt 提供 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_parameters: Option>, pub request_id: String, pub client_key_id: String, pub account_group_ids: Vec, @@ -121,8 +125,19 @@ pub struct CapabilityDeclaration { #[serde(deny_unknown_fields)] pub struct MiddlewareNextRequest { /// None 保留原设置;Some 是完整替换,字段删除必须符合对应设置类型 - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_replacement" + )] pub settings: Option, + /// 完整替换构造参数;空对象仍表示显式替换,不隐式合并或补回删除字段 + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_replacement" + )] + pub provider_parameters: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub protocol: Option, #[serde(default)] @@ -132,6 +147,15 @@ pub struct MiddlewareNextRequest { pub capabilities: Option, } +fn deserialize_replacement<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + // 只有缺省表示保留;显式值按字段类型解码,不能被 Option 当作省略 + T::deserialize(deserializer).map(Some) +} + /// 受父调用约束的不透明响应正文句柄 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MiddlewareBodyHandle { diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/outbound.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/outbound.rs new file mode 100644 index 000000000..1d53c8387 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/outbound.rs @@ -0,0 +1,73 @@ +//! 上游发送前的准备合同;正文由 RPC 二进制载荷承载 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use super::MiddlewareHeader; + +/// HTTP 发送目标或 WebSocket 候选 opening,返回值必须保留传输类型 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Target { + Http { + method: String, + uri: String, + }, + #[serde(rename = "websocket")] + WebSocket { + uri: String, + }, +} + +impl fmt::Debug for Target { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Http { method, .. } => formatter + .debug_struct("Http") + .field("method", method) + .finish_non_exhaustive(), + Self::WebSocket { .. } => formatter.debug_struct("WebSocket").finish_non_exhaustive(), + } + } +} + +/// HTTP 正文是实际内容编码后的字节;WebSocket 头是候选 opening +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestHead { + pub target: Target, + pub headers: Vec, +} + +impl fmt::Debug for RequestHead { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RequestHead") + .field("target", &self.target) + .field("headers", &self.headers.len()) + .finish() + } +} + +/// 宿主冻结的实际业务归属;改写请求不会改变账号、调用关联或计量归属 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Metadata { + pub request_id: String, + pub client_key_id: String, + pub account_group_ids: Vec, + pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + pub account_id: String, + pub attempt_index: u32, +} + +/// 入口请求与宿主归属;最终响应只返回 RequestHead 和正文载荷 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CallHead { + pub request: RequestHead, + pub metadata: Metadata, +} diff --git a/backend/crates/gateway-plugin/sdk/src/capability.rs b/backend/crates/gateway-plugin/sdk/src/capability.rs index c736e9a09..8feef3bb2 100644 --- a/backend/crates/gateway-plugin/sdk/src/capability.rs +++ b/backend/crates/gateway-plugin/sdk/src/capability.rs @@ -26,7 +26,7 @@ impl Capability { #[must_use] pub const fn contract_versions(self) -> &'static [u32] { match self { - Self::Middleware => &[5], + Self::Middleware => &[6], Self::FrontendAuthentication | Self::ModelRouter | Self::RetryPolicy @@ -91,6 +91,8 @@ pub enum Stage { WebSocket, Request, Attempt, + /// Provider 发送前的完整请求准备,不执行上游发送 + Outbound, /// Core 登记已选账号的 attempt 后才启动的受管上游执行 Upstream, Observation, diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/call.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/call.rs index 9e3df564a..3c081512d 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/call.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/call.rs @@ -1,8 +1,8 @@ //! 多边界插件只注册一次处理器,按类型选择当前调用视图 use super::super::session::{CallReply, PluginCall}; use super::{ - HttpCall, HttpResponse, MiddlewareInput, MiddlewareOutput, MiddlewareResponse, RequestCall, - ServiceCall, ServiceResponse, WebSocketCall, WebSocketMessage, + HttpCall, HttpResponse, MiddlewareInput, MiddlewareOutput, MiddlewareResponse, OutboundCall, + OutboundRequest, RequestCall, ServiceCall, ServiceResponse, WebSocketCall, WebSocketMessage, }; use crate::{CallContext, PluginFault, Stage}; @@ -11,12 +11,14 @@ pub enum MiddlewareCall { Http(Box), WebSocket(Box), Request(Box), + Outbound(Box), } pub enum MiddlewareResult { Service(ServiceResponse), Http(HttpResponse), WebSocket(Option), Request(MiddlewareResponse), + Outbound(OutboundRequest), } impl MiddlewareCall { #[must_use] @@ -26,11 +28,17 @@ impl MiddlewareCall { Self::Http(call) => &call.context, Self::WebSocket(call) => &call.context, Self::Request(call) => &call.context, + Self::Outbound(call) => &call.context, } } /// 原样进入下游,供当前处理器不感兴趣的调用复用 pub async fn forward(self) -> Result { match self { + Self::Outbound(call) => call + .next + .run(call.request) + .await + .map(MiddlewareResult::Outbound), Self::Service(call) => call.forward().await.map(MiddlewareResult::Service), Self::Http(call) => call .next @@ -57,9 +65,13 @@ impl MiddlewareInput for MiddlewareCall { || HttpCall::accepts(stage) || WebSocketCall::accepts(stage) || RequestCall::accepts(stage) + || OutboundCall::accepts(stage) } fn decode(call: PluginCall) -> Result { match call.context.stage { + Stage::Outbound => { + OutboundCall::decode(call).map(|call| Self::Outbound(Box::new(call))) + } Stage::Service => ServiceCall::decode(call).map(Box::new).map(Self::Service), Stage::Http => HttpCall::decode(call).map(Box::new).map(Self::Http), Stage::WebSocket => WebSocketCall::decode(call) @@ -76,6 +88,7 @@ impl MiddlewareOutput for MiddlewareResult { Self::Http(response) => response.encode(), Self::WebSocket(response) => response.encode(), Self::Request(response) => response.encode(), + Self::Outbound(request) => request.encode(), } } } @@ -100,3 +113,9 @@ impl From for MiddlewareResult { Self::Service(response) } } + +impl From for MiddlewareResult { + fn from(request: OutboundRequest) -> Self { + Self::Outbound(request) + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs index 1093d8f8a..4c340642a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/mod.rs @@ -3,6 +3,7 @@ mod body; mod call; mod http; +mod outbound; mod plugin; mod service; mod websocket; @@ -10,6 +11,7 @@ mod websocket; pub use body::{MiddlewareBody, MiddlewareBodySender}; pub use call::{MiddlewareCall, MiddlewareResult}; pub use http::{HttpBody, HttpCall, HttpFrame, HttpNext, HttpRequest, HttpResponse}; +pub use outbound::{OutboundCall, OutboundNext, OutboundRequest}; pub use plugin::MiddlewarePlugin; pub use service::{ServiceCall, ServiceNext, ServiceResponse, TypedServiceCall}; pub use websocket::{ @@ -37,6 +39,7 @@ pub struct MiddlewareRequest { pub body: Vec, original_protocol: String, original_settings: serde_json::Value, + original_provider_parameters: Option>, original_headers: Vec, original_body: Vec, body_replaced: bool, @@ -96,6 +99,9 @@ impl MiddlewareRequest { }; ( MiddlewareNextRequest { + provider_parameters: (self.head.provider_parameters + != self.original_provider_parameters) + .then(|| self.head.provider_parameters.unwrap_or_default()), settings: (self.head.settings != self.original_settings) .then_some(self.head.settings), protocol, @@ -339,6 +345,7 @@ impl RequestCall { context: call.context, request: MiddlewareRequest { original_settings: head.settings.clone(), + original_provider_parameters: head.provider_parameters.clone(), body: call.payload, original_body, body_replaced: false, diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/outbound.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/outbound.rs new file mode 100644 index 000000000..a761cd7b4 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/outbound.rs @@ -0,0 +1,97 @@ +//! 出站请求的类型化准备入口;一次 next 只返回准备结果 + +use std::fmt; + +use crate::call::middleware::{HANDLE_METHOD, NEXT_METHOD, outbound as wire}; +use crate::client::{CallCancellation, CallReply, HostClient, PluginCall, SessionError}; +use crate::{CallContext, PluginFault, Stage}; + +use super::{MiddlewareInput, MiddlewareOutput, invalid_input}; + +/// HTTP 正文为内容编码后的实际字节,WebSocket 正文为完整 UTF-8 消息 +#[derive(Clone, PartialEq, Eq)] +pub struct OutboundRequest { + pub head: wire::RequestHead, + pub body: Vec, +} + +impl fmt::Debug for OutboundRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OutboundRequest") + .field("head", &self.head) + .field("body_bytes", &self.body.len()) + .finish() + } +} + +/// 当前 attempt 的准备输入与单次续体,归属不从返回请求重建 +pub struct OutboundCall { + pub context: CallContext, + pub metadata: wire::Metadata, + pub request: OutboundRequest, + pub next: OutboundNext, + pub host: HostClient, + pub cancellation: CallCancellation, +} + +impl MiddlewareInput for OutboundCall { + type Output = OutboundRequest; + + fn accepts(stage: Stage) -> bool { + stage == Stage::Outbound + } + + fn decode(call: PluginCall) -> Result { + if call.method != HANDLE_METHOD || !Self::accepts(call.context.stage) { + return Err(invalid_input()); + } + let head: wire::CallHead = + serde_json::from_value(call.params).map_err(|_| invalid_input())?; + Ok(Self { + context: call.context, + metadata: head.metadata, + request: OutboundRequest { + head: head.request, + body: call.payload, + }, + next: OutboundNext { + host: call.host.clone(), + }, + host: call.host, + cancellation: call.cancellation, + }) + } +} + +impl MiddlewareOutput for OutboundRequest { + fn encode(self) -> Result { + Ok(CallReply::unary( + serde_json::to_value(self.head).map_err(|_| invalid_input())?, + self.body, + )) + } +} + +/// 消费续体只能进入下一层一次,不发送网络;外层仍可改写返回的完整请求 +pub struct OutboundNext { + host: HostClient, +} + +impl OutboundNext { + pub async fn run(self, request: OutboundRequest) -> Result { + let reply = self + .host + .call( + NEXT_METHOD, + serde_json::to_value(request.head).map_err(|_| invalid_input())?, + request.body, + ) + .await + .map_err(SessionError::into_plugin_fault)?; + Ok(OutboundRequest { + head: serde_json::from_value(reply.result).map_err(|_| invalid_input())?, + body: reply.payload, + }) + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs index ff33fb2b4..5c70ed16a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs @@ -27,7 +27,7 @@ use super::{ /// /// let contributes = Contributions::from([(Capability::Middleware, ContributionDeclaration { /// id: "acme.request-tags.tagRequest".into(), -/// version: 5, +/// version: 6, /// stages: vec![Stage::Request], /// input_formats: vec!["openai".into()], /// output_formats: vec!["openai".into()], @@ -67,7 +67,7 @@ where .contract_versions() .contains(&declaration.version) || declaration.stages.is_empty() - || declaration.stages.len() > 5 + || declaration.stages.len() > 6 || declaration.stages.iter().any(|stage| { !matches!( stage, @@ -76,6 +76,7 @@ where | Stage::Service | Stage::Request | Stage::Attempt + | Stage::Outbound ) || !C::accepts(*stage) }) || declaration diff --git a/backend/crates/gateway-plugin/sdk/src/client/mod.rs b/backend/crates/gateway-plugin/sdk/src/client/mod.rs index 307e65fda..24e444806 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/mod.rs @@ -19,10 +19,10 @@ pub use http::{HostHttpBody, HostHttpResponse}; pub use middleware::{ HttpBody, HttpCall, HttpFrame, HttpNext, HttpRequest, HttpResponse, MiddlewareBody, MiddlewareBodySender, MiddlewareCall, MiddlewareInput, MiddlewareNext, MiddlewareOutput, - MiddlewarePlugin, MiddlewareRequest, MiddlewareResponse, MiddlewareResult, RequestCall, - ServiceCall, ServiceNext, ServiceResponse, TypedServiceCall, WebSocketCall, WebSocketDirection, - WebSocketKind, WebSocketMessage, WebSocketNext, WebSocketPayload, WebSocketSender, - WebSocketSession, + MiddlewarePlugin, MiddlewareRequest, MiddlewareResponse, MiddlewareResult, OutboundCall, + OutboundNext, OutboundRequest, RequestCall, ServiceCall, ServiceNext, ServiceResponse, + TypedServiceCall, WebSocketCall, WebSocketDirection, WebSocketKind, WebSocketMessage, + WebSocketNext, WebSocketPayload, WebSocketSender, WebSocketSession, }; pub use plugin::{ AuthorError, ComposedPlugin, Empty, Method, PluginBuilder, TypedCall, TypedReply, methods, diff --git a/backend/crates/gateway-plugin/sdk/src/manifest.rs b/backend/crates/gateway-plugin/sdk/src/manifest.rs index bb4f976ca..ec886c1c1 100644 --- a/backend/crates/gateway-plugin/sdk/src/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/src/manifest.rs @@ -315,6 +315,7 @@ impl Manifest { | Stage::Service | Stage::Request | Stage::Attempt + | Stage::Outbound ) }))) || (*capability != Capability::Middleware diff --git a/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs index c6bc5f224..8401274c0 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs @@ -1,5 +1,7 @@ //! 验证中间件挂载、正文模式、增量头部与二进制帧的线协议合同 +mod outbound; + use gateway_plugin_sdk::{ Capability, Stage, call::middleware::{ @@ -11,6 +13,47 @@ use gateway_plugin_sdk::{ }; use serde_json::json; +#[test] +fn provider_parameters_distinguish_preserve_empty_and_invalid_replacement() { + let preserved: MiddlewareNextRequest = + serde_json::from_value(json!({"body":"preserve"})).unwrap(); + assert!(preserved.provider_parameters.is_none()); + for replacement in [json!({}), json!({"installation_id":null})] { + let request: MiddlewareNextRequest = serde_json::from_value(json!({ + "body":"preserve", "provider_parameters":replacement, + })) + .unwrap(); + assert_eq!( + request.provider_parameters.as_ref(), + replacement.as_object() + ); + } + for invalid in [ + json!(null), + json!([]), + json!("invalid"), + json!(42), + json!(false), + ] { + assert!( + serde_json::from_value::(json!({ + "body":"preserve", "provider_parameters":invalid, + })) + .is_err() + ); + } +} + +#[test] +fn settings_replacement_preserves_explicit_null_for_host_validation() { + let preserved: MiddlewareNextRequest = + serde_json::from_value(json!({"body":"preserve"})).unwrap(); + assert!(preserved.settings.is_none()); + let replaced: MiddlewareNextRequest = + serde_json::from_value(json!({"body":"preserve", "settings":null})).unwrap(); + assert_eq!(replaced.settings, Some(serde_json::Value::Null)); +} + #[test] fn middleware_capability_and_mounts_have_one_stable_wire_vocabulary() { assert_eq!( @@ -32,6 +75,7 @@ fn middleware_capability_and_mounts_have_one_stable_wire_vocabulary() { #[test] fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() { let request = MiddlewareRequestHead { + provider_parameters: None, settings_sources: serde_json::Value::Null, settings: serde_json::Value::Null, client_key_id: "fixture-key".into(), @@ -69,6 +113,7 @@ fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() { #[test] fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() { let preserve = MiddlewareNextRequest { + provider_parameters: None, settings: None, protocol: None, header_mutations: Vec::new(), @@ -81,6 +126,7 @@ fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() ); let replace = MiddlewareNextRequest { + provider_parameters: None, settings: None, protocol: Some("xai".into()), header_mutations: vec![ diff --git a/backend/crates/gateway-plugin/sdk/tests/call/middleware/outbound.rs b/backend/crates/gateway-plugin/sdk/tests/call/middleware/outbound.rs new file mode 100644 index 000000000..ec9743ecd --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/middleware/outbound.rs @@ -0,0 +1,28 @@ +//! 出站候选目标和可信归属的独立 wire 合同 + +use gateway_plugin_sdk::call::middleware::outbound::{CallHead, RequestHead, Target}; +use serde_json::json; + +#[test] +fn outbound_websocket_head_keeps_duplicate_header_bytes() { + let head: RequestHead = serde_json::from_value(json!({ + "target":{"kind":"websocket","uri":"wss://example.test/"}, + "headers":[{"name":"x-value","value":[128]},{"name":"x-value","value":[255]}] + })) + .unwrap(); + assert!(matches!(head.target, Target::WebSocket { .. })); + assert_eq!( + head.headers + .iter() + .map(|header| header.value.clone()) + .collect::>(), + vec![vec![128], vec![255]] + ); +} + +#[test] +fn outbound_return_cannot_replace_host_metadata() { + let value = json!({"target":{"kind":"http","method":"POST","uri":"https://example.test/"},"headers":[],"metadata":{"account_id":"forged"}}); + assert!(serde_json::from_value::(value).is_err()); + assert!(serde_json::from_value::(json!({"request":{},"metadata":{}})).is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs index b68349f26..18c51506a 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs @@ -1,5 +1,7 @@ //! 验证类型化插件处理器的组合注册、分派与能力声明一致性 +mod outbound; + use std::{ collections::BTreeMap, sync::{ diff --git a/backend/crates/gateway-plugin/sdk/tests/client/plugin/outbound.rs b/backend/crates/gateway-plugin/sdk/tests/client/plugin/outbound.rs new file mode 100644 index 000000000..01830bd09 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/client/plugin/outbound.rs @@ -0,0 +1,117 @@ +//! 出站准备处理器经真实 SDK 会话传递二进制正文并等待外层返回 + +use super::*; +use gateway_plugin_sdk::call::middleware::{ + HANDLE_METHOD, MiddlewareHeader, NEXT_METHOD, outbound, +}; +use gateway_plugin_sdk::client::{MiddlewareCall, MiddlewareResult, OutboundRequest}; + +#[tokio::test] +async fn outbound_builder_preserves_binary_payload_and_outer_changes_after_next() { + let manifest = gateway_plugin_sdk::Manifest::from_author_slice(&author_manifest( + json!({"middleware":{"version":6,"stages":["outbound"]}}), + json!([]), + )) + .unwrap(); + let contributes = manifest.contributes.clone(); + let plugin = PluginBuilder::from_manifest(manifest) + .unwrap() + .middleware(|call: MiddlewareCall| async move { + let MiddlewareCall::Outbound(call) = call else { + return call.forward().await; + }; + assert_eq!(call.metadata.account_id, "actual-account"); + assert_eq!(call.metadata.attempt_index, 3); + let mut request = call.request; + request.body.push(0x80); + let mut request = call.next.run(request).await?; + request.body.push(0xff); + Ok(MiddlewareResult::from(request)) + }) + .unwrap() + .build() + .unwrap(); + let (mut host, task) = start_session(plugin, contributes).await; + let request = outbound::RequestHead { + target: outbound::Target::Http { + method: "POST".into(), + uri: "https://example.test/native?token=private".into(), + }, + headers: vec![ + MiddlewareHeader { + name: "x-raw".into(), + value: vec![0x80], + }, + MiddlewareHeader { + name: "x-raw".into(), + value: b"second".to_vec(), + }, + ], + }; + let call = outbound::CallHead { + request: request.clone(), + metadata: outbound::Metadata { + request_id: "request".into(), + client_key_id: "key".into(), + account_group_ids: vec!["group".into()], + provider: "openai".into(), + model: Some("model".into()), + account_id: "actual-account".into(), + attempt_index: 3, + }, + }; + send_call( + &mut host, + 1, + HANDLE_METHOD, + Stage::Outbound, + serde_json::to_value(call).unwrap(), + vec![0, 255, 0], + ) + .await; + let callback = receive(&mut host).await; + let Message::Callback { + id, + parent_id, + method, + params, + } = callback.message + else { + panic!("expected next callback") + }; + assert_eq!((parent_id, method.as_str()), (1, NEXT_METHOD)); + assert_eq!( + serde_json::from_value::(params).unwrap(), + request + ); + assert_eq!(callback.payload, vec![0, 255, 0, 128]); + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id, + result: serde_json::to_value(&request).unwrap(), + }, + payload: callback.payload, + }, + ) + .await + .unwrap(); + let (head, body) = unwrap_result(receive(&mut host).await, 1); + assert_eq!( + serde_json::from_value::(head).unwrap(), + request + ); + assert_eq!(body, vec![0, 255, 0, 128, 255]); + let debug = format!( + "{:?}", + OutboundRequest { + head: request, + body + } + ); + assert!(!debug.contains("private")); + assert!(!debug.contains("second")); + assert!(!debug.contains("255")); + shutdown(&mut host, task).await; +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs index f955cf54a..0ebef12d4 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs @@ -308,7 +308,7 @@ fn middleware_contributions() -> Contributions { Capability::Middleware, ContributionDeclaration { id: "test.example.middleware".into(), - version: 5, + version: 6, stages: vec![Stage::Request], input_formats: vec!["openai".into()], output_formats: vec!["openai".into()], @@ -391,6 +391,7 @@ fn middleware_context(id: u64) -> CallContext { pub(super) fn middleware_request() -> MiddlewareRequestHead { MiddlewareRequestHead { + provider_parameters: None, settings_sources: serde_json::Value::Null, settings: serde_json::Value::Null, client_key_id: "fixture-key".into(), diff --git a/backend/crates/gateway-plugin/sdk/tests/manifest.rs b/backend/crates/gateway-plugin/sdk/tests/manifest.rs index 0e6500d1c..6923dda54 100644 --- a/backend/crates/gateway-plugin/sdk/tests/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/tests/manifest.rs @@ -23,7 +23,7 @@ fn source_manifest() -> Manifest { "contributes": { "middleware": { "id": "9acme.request-tags.tagRequest", - "version": 5, + "version": 6, "stages": ["request"], "inputFormats": ["openai"], "outputFormats": ["openai"] @@ -96,7 +96,7 @@ fn author_manifest_fills_default_ids_versions_and_fixed_stages() { "main": "bin/worker", "runtime": "trustedProcess", "contributes": { - "middleware": {"version": 5, "stages": ["attempt"]}, + "middleware": {"version": 6, "stages": ["attempt"]}, "management": {"version": 2} } }); @@ -105,7 +105,7 @@ fn author_manifest_fills_default_ids_versions_and_fixed_stages() { let management = &manifest.contributes[&Capability::Management]; assert_eq!(middleware.id, "9acme.request-tags.middleware"); - assert_eq!(middleware.version, 5); + assert_eq!(middleware.version, 6); assert_eq!(middleware.stages, [Stage::Attempt]); assert_eq!(management.id, "9acme.request-tags.management"); assert_eq!(management.version, 2); @@ -133,7 +133,7 @@ fn installation_checks_protocol_engine_and_target() { #[test] fn capability_contract_versions_are_checked_independently_of_rpc_version() { for (capability, stage, supported, needs_formats) in [ - (Capability::Middleware, Stage::Request, 5, true), + (Capability::Middleware, Stage::Request, 6, true), (Capability::Management, Stage::Management, 2, false), (Capability::UpstreamAdapter, Stage::Upstream, 5, true), (Capability::Scheduler, Stage::Scheduling, 3, false), @@ -377,7 +377,7 @@ fn manifest_round_trips_public_fields_and_rejects_unknown_fields() { #[test] fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() { let declaration = r#"{ - "id":"test.example.middleware","version":5,"stages":["request"], + "id":"test.example.middleware","version":6,"stages":["request"], "inputFormats":["openai"],"outputFormats":["openai"] }"#; let manifest = format!( @@ -412,7 +412,7 @@ fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() { fn contribution_declaration_uses_camel_case_description_fields() { let declaration = ContributionDeclaration { id: "test.example.middleware".into(), - version: 5, + version: 6, stages: vec![Stage::Request], input_formats: vec!["openai".into()], output_formats: vec!["openai".into()], @@ -421,7 +421,7 @@ fn contribution_declaration_uses_camel_case_description_fields() { serde_json::to_value(declaration).unwrap(), json!({ "id":"test.example.middleware", - "version":5, + "version":6, "stages":["request"], "inputFormats":["openai"], "outputFormats":["openai"] diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index 982846916..a87bb15ba 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -179,7 +179,8 @@ impl CodexProvider { provider_kind, requested_model.map(|model| model.as_str().to_owned()), account_id, - Box::new(move |operation, middleware_headers| { + Some(RequestParameters::baseline(lease.installation_id())), + Box::new(move |operation, middleware_headers, parameters| { Box::pin(async move { provider .execute_selected_raw_json_endpoint( @@ -187,8 +188,11 @@ impl CodexProvider { operation, middleware_headers, request, - lease, - account_selection_wait_ms, + SelectedRequest { + parameters: RequestParameters::decode(parameters)?, + lease, + account_selection_wait_ms, + }, ) .await }) @@ -203,9 +207,13 @@ impl CodexProvider { operation: Operation, middleware_headers: Vec, mut request: RawJsonEndpointRequest, - mut lease: CodexCredentialLease, - account_selection_wait_ms: u64, + selected: SelectedRequest, ) -> Result { + let SelectedRequest { + parameters, + mut lease, + account_selection_wait_ms, + } = selected; let affinity = match &operation { Operation::GenerateImage(image) => { let model = endpoint_requested_model(image.payload()); @@ -309,6 +317,11 @@ impl CodexProvider { let allows_account_state_mutation = lease.allows_account_state_mutation(); let provider_kind = ProviderKind::new(PROVIDER_NAME) .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; + let outbound_middleware = context.outbound_middleware( + provider_kind.clone(), + endpoint_requested_model(payload).map(|model| model.as_str().to_owned()), + lease.account_id().clone(), + ); let metadata = ProviderCallMetadata::for_provider_endpoint( provider_kind, lease.account_id().clone(), @@ -324,7 +337,7 @@ impl CodexProvider { lease.capacity_snapshot(), )); // Standalone Provider 端点没有可证明的账号 owner;Images/Search metadata 必须按 - // 跨账号输入收敛到当前 lease,不能沿用下游声明的账号或 installation identity + // 跨账号输入收敛到本次构造参数,不能沿用下游声明的账号身份 let turn_metadata = payload .context() .get("turn_metadata") @@ -332,17 +345,19 @@ impl CodexProvider { .and_then(|metadata| { crate::transport::request::scope_turn_metadata( metadata, - lease.installation_id(), + ¶meters.installation_id, true, ) }); let events = cold_json_response_stream(ColdJsonResponse { + parameters, client: self .client_for_request(&context)? .for_account(lease.account()) .map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)? .with_responses_api_base_url(lease.authentication().responses_api_base_url()) .with_middleware_headers(middleware_headers) + .with_outbound_middleware(outbound_middleware) .with_privacy(context.privacy(), context.cancellation().clone()), response_origin: request.response_origin, endpoint_path: request.endpoint_path, @@ -377,6 +392,7 @@ struct RawJsonEndpointRequest { } pub(super) struct ColdResponse { + pub(super) parameters: RequestParameters, pub(super) client: CodexBackendClient, pub(super) response_origin: Url, pub(super) request: CodexResponsesRequest, @@ -396,6 +412,7 @@ pub(super) struct ColdResponse { } pub(super) struct ColdJsonResponse { + pub(super) parameters: RequestParameters, pub(super) client: CodexBackendClient, pub(super) response_origin: Url, pub(super) endpoint_path: &'static str, @@ -640,7 +657,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea let response = create_json_attempt( &request, &active_account, - request.lease.installation_id(), + &request.parameters.installation_id, &authorization, cookie_header.as_ref(), account_selection, @@ -691,7 +708,7 @@ pub(super) fn cold_json_response_stream(request: ColdJsonResponse) -> EventStrea .selector .capture_response_cookies( &active_account, - &request.response_origin, + response.diagnostics.response_origin.as_ref().unwrap_or(&request.response_origin), &response.set_cookie_headers, ) .await @@ -759,6 +776,7 @@ fn image_response_metering( pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream { let ColdResponse { + parameters, client, response_origin, request, @@ -815,7 +833,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream { &request, &request_id, &active_account, - lease.installation_id(), + ¶meters.installation_id, &authorization, cookie_header.as_ref(), account_selection, @@ -918,7 +936,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream { match selector .capture_response_cookies( &active_account, - &response_origin, + response.diagnostics.response_origin.as_ref().unwrap_or(&response_origin), &response.set_cookie_headers, ) .await diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index ff6c459c5..b0f4f19c7 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -48,6 +48,7 @@ pub(super) struct MappedProviderFailure { pub(super) cyber_policy_failure: bool, pub(super) upstream_capacity_failure: bool, pub(super) set_cookie_headers: Vec, + pub(super) response_origin: Option, pub(super) rate_limit_headers: Vec<(String, String)>, pub(super) observation: Option, pub(super) capture_response_cookies: bool, @@ -63,6 +64,7 @@ impl MappedProviderFailure { cyber_policy_failure: false, upstream_capacity_failure: false, set_cookie_headers: Vec::new(), + response_origin: None, rate_limit_headers: Vec::new(), observation: None, capture_response_cookies: false, @@ -345,7 +347,10 @@ pub(super) async fn apply_failure( .selector .capture_response_cookies( account, - context.response_origin, + failure + .response_origin + .as_ref() + .unwrap_or(context.response_origin), &failure.set_cookie_headers, ) .await @@ -738,6 +743,9 @@ pub(super) fn map_client_error( uncertain_state: UpstreamSendState, observe_transport: bool, ) -> MappedProviderFailure { + if let CodexClientError::Outbound(error) = error { + return MappedProviderFailure::plain(*error); + } let local_connection_capacity = crate::transport::connection::is_admission_failure(&error); let diagnostic = if local_connection_capacity { Some( @@ -789,13 +797,15 @@ pub(super) fn map_client_error( let connect_retry = !local_connection_capacity && matches!(&error, CodexClientError::Http(error) if transient_http_connect(error)); let mut failure = match &error { - CodexClientError::Privacy(_) => MappedProviderFailure::plain( - provider_error( - ProviderErrorKind::InvalidRequest, - UpstreamSendState::NotSent, + CodexClientError::Outbound(_) | CodexClientError::Privacy(_) => { + MappedProviderFailure::plain( + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + .with_retry_prohibited(), ) - .with_retry_prohibited(), - ), + } CodexClientError::ConnectionBudgetExhausted => MappedProviderFailure::plain( provider_error(ProviderErrorKind::Timeout, UpstreamSendState::NotSent) .with_connection_retry(gateway_core::engine::AttemptTransport::Fallback), @@ -1021,6 +1031,7 @@ fn client_diagnostic(error: &CodexClientError) -> Option { "invalid_sse", "OpenAI HTTP returned an invalid Responses event stream".to_owned(), ), + CodexClientError::Outbound(_) => return None, CodexClientError::Privacy(error) => ( "prepare", "privacy_policy_rejected", @@ -1468,6 +1479,7 @@ pub(super) fn map_upstream_failure( // 普通 5xx 与未知流内错误不足以证明容量拒绝,不能用于冻结账号 upstream_capacity_failure: capacity_unavailable, set_cookie_headers: failure.set_cookie_headers, + response_origin: failure.response_origin, rate_limit_headers: failure.rate_limit_headers, observation, capture_response_cookies: !matches!( diff --git a/backend/crates/providers/openai/src/provider/live.rs b/backend/crates/providers/openai/src/provider/live.rs index 3e2736f9f..8a13454ee 100644 --- a/backend/crates/providers/openai/src/provider/live.rs +++ b/backend/crates/providers/openai/src/provider/live.rs @@ -280,7 +280,8 @@ impl CodexProvider { provider_kind, Some(upstream_model.as_str().to_owned()), account_id, - Box::new(move |operation, middleware_headers| { + Some(RequestParameters::baseline(lease.installation_id())), + Box::new(move |operation, middleware_headers, parameters| { Box::pin(async move { provider .execute_selected_live_call( @@ -288,8 +289,11 @@ impl CodexProvider { operation, upstream_model, middleware_headers, - lease, - account_selection_wait_ms, + SelectedRequest { + parameters: RequestParameters::decode(parameters)?, + lease, + account_selection_wait_ms, + }, ) .await }) @@ -304,9 +308,13 @@ impl CodexProvider { operation: Operation, upstream_model: UpstreamModelId, middleware_headers: Vec, - mut lease: CodexCredentialLease, - account_selection_wait_ms: u64, + selected: SelectedRequest, ) -> Result { + let SelectedRequest { + parameters, + mut lease, + account_selection_wait_ms, + } = selected; let Operation::ProviderHttp(request) = operation else { return Err(provider_error( ProviderErrorKind::InvalidRequest, @@ -333,6 +341,11 @@ impl CodexProvider { let allows_account_state_mutation = lease.allows_account_state_mutation(); let provider_kind = ProviderKind::new(PROVIDER_NAME) .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; + let outbound_middleware = context.outbound_middleware( + provider_kind.clone(), + Some(upstream_model.as_str().to_owned()), + lease.account_id().clone(), + ); let metadata = ProviderCallMetadata::new( provider_kind, upstream_model.clone(), @@ -361,12 +374,14 @@ impl CodexProvider { .cloned() .collect::>(); let events = cold_live_call_stream(ColdLiveCall { + parameters, client: self .client_for_request(&context)? .for_account(lease.account()) .map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)? .with_responses_api_base_url(lease.authentication().responses_api_base_url()) - .with_middleware_headers(middleware_headers), + .with_middleware_headers(middleware_headers) + .with_outbound_middleware(outbound_middleware), registry: Arc::clone(&self.live_registry), response_origin: self.live_calls_url.clone(), endpoint_query: request.query().map(str::to_owned), @@ -392,6 +407,7 @@ impl CodexProvider { } struct ColdLiveCall { + parameters: RequestParameters, client: CodexBackendClient, registry: Arc, response_origin: Url, @@ -435,7 +451,7 @@ fn cold_live_call_stream(request: ColdLiveCall) -> EventStream { authorization.expose_secret(), active_account.upstream_account_id(), &request_id, - Some(request.lease.installation_id()), + Some(&request.parameters.installation_id), ); request_context.trace = Some(&trace); request_context.account_selection = CodexAccountSelectionTelemetry::new( diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 6603b9322..03fb72e27 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -107,6 +107,7 @@ mod execution; mod failure; mod live; mod observation; +mod parameters; mod upstream_adapter; mod workers; pub(crate) use live::{CodexLiveGateway, CodexLiveRegistry}; @@ -117,6 +118,7 @@ use execution::*; pub use failure::openai_failure_affects_account_score; use failure::*; use observation::*; +use parameters::RequestParameters; pub(crate) use workers::worker_contributions; const PROVIDER_NAME: &str = "openai"; @@ -219,9 +221,14 @@ impl PreparedGenerateRequest { } struct SelectedGenerate { + request: SelectedRequest, + frozen_requirements: CapabilityRequirements, +} + +struct SelectedRequest { lease: CodexCredentialLease, account_selection_wait_ms: u64, - frozen_requirements: CapabilityRequirements, + parameters: RequestParameters, } impl CodexProvider { @@ -749,7 +756,10 @@ impl Provider for CodexProvider { provider_kind, Some(upstream_model.as_str().to_owned()), account_id, - Box::new(move |operation, middleware_headers| { + adapter + .is_none() + .then(|| RequestParameters::baseline(lease.installation_id())), + Box::new(move |operation, middleware_headers, parameters| { Box::pin(async move { if let Some(adapter) = adapter { return provider.execute_upstream_adapter( @@ -770,8 +780,11 @@ impl Provider for CodexProvider { terminal_model, terminal_context, SelectedGenerate { - lease, - account_selection_wait_ms, + request: SelectedRequest { + parameters: RequestParameters::decode(parameters)?, + lease, + account_selection_wait_ms, + }, frozen_requirements, }, ) @@ -793,8 +806,12 @@ impl CodexProvider { selected: SelectedGenerate, ) -> Result { let SelectedGenerate { - mut lease, - account_selection_wait_ms, + request: + SelectedRequest { + parameters, + mut lease, + account_selection_wait_ms, + }, frozen_requirements, } = selected; let Operation::Generate(generate) = operation else { @@ -934,7 +951,7 @@ impl CodexProvider { .then(|| crate::transport::diagnostics::evidence::request_state(&upstream_request)); scope_request_to_account( &mut upstream_request, - lease.installation_id(), + ¶meters.installation_id, account_scope, ); if let Some(before) = state_before { @@ -1045,6 +1062,11 @@ impl CodexProvider { )); } apply_transport(&mut upstream_request, transport); + let outbound_middleware = context.outbound_middleware( + provider_kind.clone(), + Some(upstream_model.as_str().to_owned()), + lease.account_id().clone(), + ); let metadata = ProviderCallMetadata::new( provider_kind, upstream_model.clone(), @@ -1084,6 +1106,7 @@ impl CodexProvider { AttemptTransport::Default | AttemptTransport::Fallback => 0, }; let events = cold_response_stream(ColdResponse { + parameters, client: self .client_for_request(&context)? .for_account(lease.account()) @@ -1092,6 +1115,7 @@ impl CodexProvider { .with_connection_budget(context.connection_budget().clone()) .with_response_control(context.response_control().cloned()) .with_middleware_headers(middleware_headers) + .with_outbound_middleware(outbound_middleware) .with_privacy(context.privacy(), context.cancellation().clone()), response_origin: self.responses_url.clone(), request: upstream_request, diff --git a/backend/crates/providers/openai/src/provider/parameters.rs b/backend/crates/providers/openai/src/provider/parameters.rs new file mode 100644 index 000000000..ac0e3d95a --- /dev/null +++ b/backend/crates/providers/openai/src/provider/parameters.rs @@ -0,0 +1,35 @@ +//! OpenAI 原生请求的构造参数;只解释本次值,不维护插件身份策略 + +use gateway_core::{ + error::{ProviderError, ProviderErrorKind}, + upstream::UpstreamSendState, +}; +use serde::Deserialize; +use serde_json::{Map, Value}; + +#[derive(Deserialize)] +pub(super) struct RequestParameters { + pub(super) installation_id: String, +} + +impl RequestParameters { + pub(super) fn baseline(installation_id: &str) -> Map { + Map::from_iter([( + "installation_id".into(), + Value::String(installation_id.to_owned()), + )]) + } + + pub(super) fn decode(parameters: Option>) -> Result { + let invalid = || { + ProviderError::new( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + .with_retry_prohibited() + }; + // 参数基线已在选号后提供,显式删除和类型错误不能回退为账号默认身份 + serde_json::from_value(Value::Object(parameters.ok_or_else(invalid)?)) + .map_err(|_| invalid()) + } +} diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index 5fe0dc60c..0f8026165 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -195,6 +195,8 @@ impl fmt::Debug for CodexClientVisibleUpstreamResponse { /// Codex 上游 HTTP 客户端错误 #[derive(Error)] pub enum CodexClientError { + #[error("outbound request preparation failed")] + Outbound(Box), #[error("privacy policy rejected the request: {0}")] Privacy(#[from] gateway_core::settings::privacy::PrivacyError), #[error("connection recovery budget exhausted")] @@ -224,7 +226,6 @@ pub enum CodexClientError { /// 请求头值无效 #[error("invalid request header value: {0}")] InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), - /// 中间件业务头试图覆盖 Provider 已构造的受管头 /// SSE 响应解析失败 #[error("invalid upstream SSE response: {0}")] InvalidSse(#[from] SseError), @@ -278,6 +279,7 @@ pub enum CodexClientError { impl fmt::Debug for CodexClientError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Outbound(_) => formatter.write_str("CodexClientError::Outbound([REDACTED])"), Self::ConnectionBudgetExhausted => { formatter.write_str("CodexClientError::ConnectionBudgetExhausted") } @@ -348,7 +350,8 @@ impl CodexClientError { Self::Upstream { transport, .. } | Self::ErrorBodyRead { transport, .. } => { Some(*transport) } - Self::Privacy(_) + Self::Outbound(_) + | Self::Privacy(_) | Self::ConnectionBudgetExhausted | Self::CustomCa(_) | Self::InvalidHeaderName(_) @@ -700,6 +703,7 @@ pub struct CodexBackendClient { pub(super) outbound_proxy: Option, pub(super) egress_key: String, pub(super) middleware_headers: Vec, + pub(super) outbound_middleware: Option, } impl CodexBackendClient { @@ -961,7 +965,8 @@ pub(super) async fn http_json_upstream_error( ) -> CodexClientError { let status = response.status(); let headers = response.headers(); - let diagnostics = super::response_meta::diagnostics(Some(status.as_u16()), headers); + let diagnostics = super::response_meta::diagnostics(Some(status.as_u16()), headers) + .with_response_origin(Some(response.url().clone())); let set_cookie_headers = super::response_meta::set_cookie_headers(headers); let rate_limit_headers = super::response_meta::rate_limit_headers(headers); let retry_after_seconds = retry_after_seconds(headers, None); diff --git a/backend/crates/providers/openai/src/transport/client_json.rs b/backend/crates/providers/openai/src/transport/client_json.rs index 2625635cc..d399b2147 100644 --- a/backend/crates/providers/openai/src/transport/client_json.rs +++ b/backend/crates/providers/openai/src/transport/client_json.rs @@ -76,23 +76,28 @@ impl CodexBackendClient { .cloned() .unwrap_or_default() .exchange("http_json"); + let outbound = self + .prepare_http_request(endpoint_url(&self.base_url, endpoint_path), headers, body) + .await?; trace.headers( "upstream.request.headers", - serde_json::json!({ - "method": "POST", "endpoint": endpoint_path, - }), - headers + serde_json::json!({"method": outbound.method().as_str(), "transport": "http_json"}), + outbound + .headers() .iter() .map(|(name, value)| (name.as_str(), value.as_bytes())), ); - trace.capture("upstream.request.body", &body); + trace.capture( + "upstream.request.body", + outbound + .body() + .and_then(reqwest::Body::as_bytes) + .unwrap_or_default(), + ); let headers_started_at = Instant::now(); let response = self .client - .post(endpoint_url(&self.base_url, endpoint_path)) - .headers(headers) - .body(body) - .send() + .execute(outbound) .await .map_err(CodexClientError::HttpJson)?; let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed()); @@ -124,7 +129,8 @@ impl CodexBackendClient { .await); } - let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()); + let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()) + .with_response_origin(Some(response.url().clone())); let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); let rate_limit_headers = response_meta::rate_limit_headers(response.headers()); let response_metadata = response_meta::response_metadata(response.headers()); diff --git a/backend/crates/providers/openai/src/transport/client_live.rs b/backend/crates/providers/openai/src/transport/client_live.rs index 7989f8392..828909970 100644 --- a/backend/crates/providers/openai/src/transport/client_live.rs +++ b/backend/crates/providers/openai/src/transport/client_live.rs @@ -122,24 +122,26 @@ impl CodexBackendClient { .cloned() .unwrap_or_default() .exchange("http_json"); + let outbound = self.prepare_http_request(url, headers, body).await?; trace.headers( "upstream.request.headers", - serde_json::json!({ - "method": "POST", "endpoint": url, - }), - headers + serde_json::json!({"method": outbound.method().as_str(), "transport": "http_json"}), + outbound + .headers() .iter() .map(|(name, value)| (name.as_str(), value.as_bytes())), ); - trace.capture("upstream.request.body", &body); - + trace.capture( + "upstream.request.body", + outbound + .body() + .and_then(reqwest::Body::as_bytes) + .unwrap_or_default(), + ); let headers_started_at = Instant::now(); let response = self .client - .post(url) - .headers(headers) - .body(body) - .send() + .execute(outbound) .await .map_err(CodexClientError::HttpJson)?; let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed()); @@ -171,7 +173,8 @@ impl CodexBackendClient { .await); } - let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()); + let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()) + .with_response_origin(Some(response.url().clone())); let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); let rate_limit_headers = response_meta::rate_limit_headers(response.headers()); let retry_after_seconds = retry_after_seconds(response.headers(), None); diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index f80ec6a30..e1f778ff8 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -22,7 +22,7 @@ use crate::transport::{ catalog_etag, parse_codex_model_catalog, }, diagnostics::CodexUpstreamSendPhase, - endpoints::{CODEX_RESPONSES_PATH, endpoint_url}, + endpoints::endpoint_url, headers::websocket_header_pairs, profile::CodexWireProfileState, protocol::{ @@ -76,6 +76,7 @@ impl CodexBackendClient { outbound_proxy: None, egress_key: String::new(), middleware_headers: Vec::new(), + outbound_middleware: None, base_url, official_base_url: crate::OFFICIAL_CODEX_BASE_URL.to_owned(), protocol: OpenAiUpstreamProtocol::Codex, @@ -117,7 +118,6 @@ impl CodexBackendClient { let profile = self.profile.snapshot(); let mut headers = self.request_headers_for_http_response(&profile, upstream_request, context)?; - let headers_started_at = Instant::now(); // OAuth 请求遵循 Codex 压缩合同;API Key 上游使用普通 JSON // Codex 上游只交付 SSE;即使下游请求 `stream: false`,也要上游流式执行, // 再由 API 层收集 canonical events 并返回完整 JSON @@ -147,31 +147,43 @@ impl CodexBackendClient { if let Some(body) = upstream_body.as_object() { super::diagnostics::evidence::record_request( &trace, - "provider.request.outbound", + if self.outbound_middleware.is_some() { + "provider.request.native" + } else { + "provider.request.outbound" + }, body, "http_sse", Some(&headers), ); } - trace.headers( - "upstream.request.headers", - serde_json::json!({ - "method": "POST", "endpoint": CODEX_RESPONSES_PATH, - }), - headers - .iter() - .map(|(name, value)| (name.as_str(), value.as_bytes())), - ); - trace.capture("upstream.request.body", &body); - let client = self.http_opening_client()?; - let outbound = client.post(endpoint).headers(headers); let body = if self.protocol == OpenAiUpstreamProtocol::Codex { zstd::stream::encode_all(std::io::Cursor::new(body), 3) .map_err(CodexClientError::RequestCompression)? } else { body }; - let response = outbound.body(body).send().await?; + let outbound = self + .prepare_http_request(endpoint, headers, body.into()) + .await?; + trace.headers( + "upstream.request.headers", + serde_json::json!({"method": outbound.method().as_str(), "transport": "http_sse"}), + outbound + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.as_bytes())), + ); + trace.capture( + "upstream.request.body", + outbound + .body() + .and_then(reqwest::Body::as_bytes) + .unwrap_or_default(), + ); + let client = self.http_opening_client()?; + let headers_started_at = Instant::now(); + let response = client.execute(outbound).await?; let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed()); let http_version = http_version_name(response.version()).to_string(); let status = response.status(); @@ -186,7 +198,8 @@ impl CodexBackendClient { .iter() .map(|(name, value)| (name.as_str(), value.as_bytes())), ); - let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()); + let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()) + .with_response_origin(Some(response.url().clone())); let turn_state = response_meta::turn_state(response.headers()); let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); let rate_limit_headers = response_meta::rate_limit_headers(response.headers()); @@ -312,7 +325,11 @@ impl CodexBackendClient { if let Some(body) = body.as_object() { super::diagnostics::evidence::record_request( &context.trace.cloned().unwrap_or_default(), - "provider.request.outbound", + if self.outbound_middleware.is_some() { + "provider.request.native" + } else { + "provider.request.outbound" + }, body, "websocket", Some(&headers), @@ -338,6 +355,8 @@ impl CodexBackendClient { .map_err(CodexClientError::WebSocketEncode)?; websocket_create.connection.outbound_proxy = self.outbound_proxy.clone(); websocket_create.connection.connection_budget = self.connection_budget.clone(); + let (websocket_create, outbound_profile) = + self.prepare_websocket_request(websocket_create).await?; context.trace.cloned().unwrap_or_default().headers( "upstream.request.headers", serde_json::json!({"transport": "websocket", "phase": "prepared_opening"}), @@ -353,7 +372,10 @@ impl CodexBackendClient { let artifact = websocket_audit_artifact_from_attempt( &websocket_request, websocket_create.connection().opening_audit_snapshot(), - websocket_payload_audit_snapshot(&websocket_request), + // 插件准备后只由受控 wire capture 记录实际正文,不把原生 JSON 冒充最终帧 + self.outbound_middleware + .is_none() + .then(|| websocket_payload_audit_snapshot(&websocket_request)), ); if let Err(error) = write_websocket_audit_artifact_from_env(&artifact, self.timezone).await @@ -361,11 +383,15 @@ impl CodexBackendClient { tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact"); } } - let connection_profile = websocket_connection_profile( + let mut connection_profile = websocket_connection_profile( &headers, &self.middleware_headers, original_headers.as_ref(), ); + if let Some(profile) = outbound_profile { + connection_profile.push('\0'); + connection_profile.push_str(&profile); + } let pool_key = self.websocket_pool_key(request, context, pool_account_id, &connection_profile); let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key); @@ -381,11 +407,26 @@ impl CodexBackendClient { TransportRequirement::HttpRequired => None, }; let prepare_started_at = Instant::now(); + let opening_origin_key = self.outbound_middleware.as_ref().map(|_| { + format!( + "{}\0{}", + websocket_origin_key( + &websocket_create + .connection() + .endpoint() + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1) + ), + self.egress_key + ) + }); let prepared = prepare_response_create_request_with_pool( &websocket_create, pool, &self.websocket_origin_breaker, - &self.websocket_origin_key, + opening_origin_key + .as_deref() + .unwrap_or(&self.websocket_origin_key), fast_path_budget, requirement.requires_websocket(), Some(DEFAULT_STREAM_IDLE_TIMEOUT), @@ -581,7 +622,10 @@ impl CodexBackendClient { let mut key = CodexWebSocketPoolKey::new(&self.base_url, account_id, conversation_id) .with_egress_key(&self.egress_key) .with_outbound_proxy(self.outbound_proxy.as_ref()) - .with_connection_profile(connection_profile); + .with_connection_profile(format!( + "{connection_profile}\0{}", + context.installation_id.unwrap_or_default(), + )); if let Some(connection_id) = request.downstream_websocket_connection_id.as_deref() { key = key.with_downstream_connection_id(connection_id); } @@ -613,7 +657,8 @@ impl CodexBackendClient { } let response = request.send().await?; let status = response.status(); - let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()); + let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()) + .with_response_origin(Some(response.url().clone())); let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); let retry_after_seconds = retry_after_seconds(response.headers(), None); let etag = status diff --git a/backend/crates/providers/openai/src/transport/diagnostics/mod.rs b/backend/crates/providers/openai/src/transport/diagnostics/mod.rs index 0b462c331..ec0c686a2 100644 --- a/backend/crates/providers/openai/src/transport/diagnostics/mod.rs +++ b/backend/crates/providers/openai/src/transport/diagnostics/mod.rs @@ -79,6 +79,7 @@ pub struct CodexUpstreamFailure { pub(crate) usage_limit_resets_at: Option, pub(crate) retry_after_seconds: Option, pub(crate) request_id: Option, + pub(crate) response_origin: Option, pub(crate) set_cookie_headers: Vec, pub(crate) rate_limit_headers: Vec<(String, String)>, pub(crate) send_phase: CodexUpstreamSendPhase, @@ -124,6 +125,7 @@ impl CodexUpstreamFailure { .flatten(), retry_after_seconds, request_id: diagnostics.request_id.clone(), + response_origin: diagnostics.response_origin.clone(), set_cookie_headers: set_cookie_headers.to_vec(), rate_limit_headers: rate_limit_headers.to_vec(), send_phase, @@ -171,6 +173,7 @@ impl CodexUpstreamFailure { .request_id .clone() .or_else(|| diagnostics.request_id.clone()), + response_origin: diagnostics.response_origin.clone(), set_cookie_headers: set_cookie_headers.to_vec(), rate_limit_headers: rate_limit_headers.to_vec(), send_phase, @@ -282,16 +285,43 @@ enum UpstreamFailureSource { SseFailure, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Clone, Default, PartialEq, Eq)] pub struct CodexUpstreamDiagnostics { pub status_code: Option, pub request_id: Option, pub identity_authorization_error: Option, pub identity_error_code: Option, pub trace_headers: Vec<(String, String)>, + /// 实际响应所属 URL;WebSocket 沿用实际 opening,不能使用本次候选目标 + pub response_origin: Option, +} + +impl std::fmt::Debug for CodexUpstreamDiagnostics { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CodexUpstreamDiagnostics") + .field("status_code", &self.status_code) + .field("request_id", &self.request_id) + .field( + "identity_authorization_error", + &self.identity_authorization_error, + ) + .field("identity_error_code", &self.identity_error_code) + .field("trace_headers", &self.trace_headers) + .field( + "response_origin", + &self.response_origin.as_ref().map(|_| ""), + ) + .finish() + } } impl CodexUpstreamDiagnostics { + pub(crate) fn with_response_origin(mut self, url: Option) -> Self { + self.response_origin = url; + self + } + pub(crate) fn error_event_request_id(value: &Value) -> Option { let headers = value.get("headers")?.as_object()?; let mut mapped = HeaderMap::new(); @@ -326,6 +356,7 @@ impl CodexUpstreamDiagnostics { identity_error_code: header_value(headers, IDENTITY_ERROR_JSON_HEADER) .and_then(|encoded| decode_identity_error_code(&encoded)), trace_headers: trace_headers(headers), + response_origin: None, } } diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index eabb3b22c..ee7adf7b7 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -13,6 +13,7 @@ pub mod diagnostics; mod downstream; pub mod endpoints; pub mod headers; +mod outbound; pub mod profile; pub mod profile_avatar; pub mod profile_statistics; diff --git a/backend/crates/providers/openai/src/transport/outbound.rs b/backend/crates/providers/openai/src/transport/outbound.rs new file mode 100644 index 000000000..38c9459da --- /dev/null +++ b/backend/crates/providers/openai/src/transport/outbound.rs @@ -0,0 +1,192 @@ +//! 原生请求完成编码后的插件准备,不持有发送与续接决策 + +use std::collections::BTreeSet; + +use bytes::Bytes; +use gateway_core::{ + engine::middleware::{ + MiddlewareHeader, + outbound::{Pipeline, Request, Target}, + }, + error::{ProviderError, ProviderErrorKind}, + upstream::UpstreamSendState, +}; +use reqwest::{Method, Url, header::HeaderMap}; +use sha2::{Digest, Sha256}; +use tungstenite::http; + +use super::{ + client::{CodexBackendClient, CodexClientError, CodexClientResult}, + websocket::CodexWebSocketRequest, +}; + +impl CodexBackendClient { + pub(crate) fn with_outbound_middleware(mut self, pipeline: Option) -> Self { + self.outbound_middleware = pipeline; + self + } + + pub(super) async fn prepare_http_request( + &self, + endpoint: String, + headers: HeaderMap, + body: Bytes, + ) -> CodexClientResult { + let Some(pipeline) = &self.outbound_middleware else { + // 未命中出站插件时沿用原生 RequestBuilder 的请求构造语义 + return self + .client + .post(endpoint) + .headers(headers) + .body(body) + .build() + .map_err(CodexClientError::Http); + }; + let prepared = pipeline + .prepare(Request { + target: Target::Http { + method: Method::POST, + uri: endpoint.parse().map_err(|_| invalid_request())?, + }, + headers: headers + .iter() + .map(|(name, value)| { + MiddlewareHeader::new( + name.as_str(), + Bytes::copy_from_slice(value.as_bytes()), + ) + }) + .collect(), + body, + }) + .await + .map_err(|error| CodexClientError::Outbound(Box::new(error)))?; + let headers = prepared + .header_map() + .map_err(|error| CodexClientError::Outbound(Box::new(error)))?; + let Target::Http { method, uri } = prepared.target else { + return Err(invalid_request()); + }; + let url = Url::parse(&uri.to_string()).map_err(|_| invalid_request())?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(invalid_request()); + } + let mut request = reqwest::Request::new(method, url); + *request.headers_mut() = headers; + *request.body_mut() = Some(prepared.body.into()); + Ok(request) + } + + pub(super) async fn prepare_websocket_request( + &self, + mut request: CodexWebSocketRequest, + ) -> CodexClientResult<(CodexWebSocketRequest, Option)> { + let Some(pipeline) = &self.outbound_middleware else { + return Ok((request, None)); + }; + let uri: http::Uri = request + .connection() + .endpoint() + .parse() + .map_err(|_| invalid_request())?; + let outbound = Request { + target: Target::WebSocket { uri: uri.clone() }, + headers: request + .connection() + .headers() + .iter() + .map(|(name, value)| { + MiddlewareHeader::new(name.clone(), Bytes::copy_from_slice(value.as_bytes())) + }) + .collect(), + body: request.take_payload_text().into(), + }; + let original_headers = outbound + .header_map() + .map_err(|error| CodexClientError::Outbound(Box::new(error)))?; + let prepared = pipeline + .prepare(outbound) + .await + .map_err(|error| CodexClientError::Outbound(Box::new(error)))?; + let headers = prepared + .header_map() + .map_err(|error| CodexClientError::Outbound(Box::new(error)))?; + let Target::WebSocket { uri: prepared_uri } = prepared.target else { + return Err(invalid_request()); + }; + let endpoint = prepared_uri.to_string(); + let url = Url::parse(&endpoint).map_err(|_| invalid_request())?; + if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() { + return Err(invalid_request()); + } + let profile = opening_changes(&uri, &prepared_uri, &original_headers, &headers); + let headers = prepared + .headers + .into_iter() + .map(|header| { + let (name, value) = header.into_parts(); + Ok(( + name, + std::str::from_utf8(&value) + .map_err(|_| invalid_request())? + .to_owned(), + )) + }) + .collect::>>()?; + let payload = String::from_utf8(prepared.body.into()).map_err(|_| invalid_request())?; + // 只替换候选 opening 和正文;原请求的 continuation 归属不从插件正文重建 + request.replace_outbound(endpoint, headers, payload); + request + .connection + .validate_prepared_opening() + .map_err(|_| invalid_request())?; + Ok((request, profile)) + } +} + +fn invalid_request() -> CodexClientError { + CodexClientError::Outbound(Box::new( + ProviderError::new( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + .with_retry_prohibited(), + )) +} + +fn opening_changes( + original_uri: &http::Uri, + uri: &http::Uri, + original: &HeaderMap, + headers: &HeaderMap, +) -> Option { + let mut digest = Sha256::new(); + let mut changed = original_uri != uri; + if changed { + digest.update(uri.to_string().as_bytes()); + } + let names: BTreeSet<_> = original + .keys() + .chain(headers.keys()) + .map(|name| name.as_str()) + .collect(); + for name in names { + if original + .get_all(name) + .iter() + .eq(headers.get_all(name).iter()) + { + continue; + } + changed = true; + digest.update(name.len().to_le_bytes()); + digest.update(name.as_bytes()); + digest.update(headers.get_all(name).iter().count().to_le_bytes()); + for value in headers.get_all(name) { + digest.update(value.len().to_le_bytes()); + digest.update(value.as_bytes()); + } + } + // 原生随机握手键和追踪头未被插件改写时,不影响新链复用 + changed.then(|| hex::encode(digest.finalize())) +} diff --git a/backend/crates/providers/openai/src/transport/protocol/websocket.rs b/backend/crates/providers/openai/src/transport/protocol/websocket.rs index b883414bc..1fcbb752d 100644 --- a/backend/crates/providers/openai/src/transport/protocol/websocket.rs +++ b/backend/crates/providers/openai/src/transport/protocol/websocket.rs @@ -172,13 +172,13 @@ pub fn websocket_payload_audit_snapshot(request: &CodexResponsesRequest) -> Payl pub fn websocket_audit_artifact_from_attempt( request: &CodexResponsesRequest, opening: OpeningAuditSnapshot, - payload: PayloadAuditSnapshot, + payload: Option, ) -> WebSocketAuditArtifact { WebSocketAuditArtifact { transport_mode: transport_requirement(request).as_str().to_string(), fallback_allowed: transport_requirement(request).allows_pre_send_http_fallback(), opening: Some(opening), - payload: Some(payload), + payload, } } diff --git a/backend/crates/providers/openai/src/transport/websocket/audit.rs b/backend/crates/providers/openai/src/transport/websocket/audit.rs index 14944024c..7fbb4bde3 100644 --- a/backend/crates/providers/openai/src/transport/websocket/audit.rs +++ b/backend/crates/providers/openai/src/transport/websocket/audit.rs @@ -79,16 +79,9 @@ impl CodexWebSocketConnection { fn request_line_for_endpoint(endpoint: &str) -> String { let path = reqwest::Url::parse(endpoint) .ok() - .map(|url| { - let mut path = url.path().to_string(); - if let Some(query) = url.query() { - path.push('?'); - path.push_str(query); - } - path - }) + .map(|url| url.path().to_owned()) .filter(|path| !path.is_empty()) - .unwrap_or_else(|| endpoint.to_string()); + .unwrap_or_else(|| "".to_owned()); format!("GET {path} HTTP/1.1") } diff --git a/backend/crates/providers/openai/src/transport/websocket/coordinator.rs b/backend/crates/providers/openai/src/transport/websocket/coordinator.rs index 6049a2bae..b3979fa3b 100644 --- a/backend/crates/providers/openai/src/transport/websocket/coordinator.rs +++ b/backend/crates/providers/openai/src/transport/websocket/coordinator.rs @@ -501,7 +501,7 @@ async fn connect_websocket_connection( Ok(( PooledWebSocketConnection { websocket, - metadata: websocket_connection_metadata(&response), + metadata: websocket_connection_metadata(&response, connection), continuation: WebSocketContinuationState::default(), created_at: tokio::time::Instant::now(), }, diff --git a/backend/crates/providers/openai/src/transport/websocket/handshake.rs b/backend/crates/providers/openai/src/transport/websocket/handshake.rs index 91d5704cd..180d503df 100644 --- a/backend/crates/providers/openai/src/transport/websocket/handshake.rs +++ b/backend/crates/providers/openai/src/transport/websocket/handshake.rs @@ -39,6 +39,32 @@ const WEBSOCKET_EXTENSIONS: &str = "permessage-deflate; client_max_window_bits"; const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); impl CodexWebSocketConnection { + /// 复用传输库的握手编码检查,不能在拨号后才发现插件输出不可表示 + pub(crate) fn validate_prepared_opening(&mut self) -> Result<(), tungstenite::Error> { + let mut offers = self + .headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("sec-websocket-extensions")); + self.permessage_deflate = match (offers.next(), offers.next()) { + (None, None) => false, + (Some((_, value)), None) if value == WEBSOCKET_EXTENSIONS => true, + // 锁定的客户端按配置生成 offer,不支持任意原始扩展声明 + _ => { + return Err(tungstenite::Error::Protocol( + tungstenite::error::ProtocolError::InvalidHeader( + tungstenite::http::HeaderName::from_static("sec-websocket-extensions") + .into(), + ), + )); + } + }; + tungstenite::handshake::client::generate_request( + websocket_handshake_request(self)?, + Some(&websocket_config(self).extensions), + )?; + Ok(()) + } + /// 构造 Responses WebSocket 连接描述 pub fn responses( base_url: &str, @@ -64,6 +90,7 @@ impl CodexWebSocketConnection { Self { endpoint, headers, + permessage_deflate: true, outbound_proxy: None, connection_budget: None, } @@ -155,6 +182,7 @@ pub(super) async fn send_websocket_request( pub(super) fn websocket_connection_metadata( response: &WsResponse>>, + connection: &CodexWebSocketConnection, ) -> CodexWebSocketConnectionMetadata { CodexWebSocketConnectionMetadata { turn_state: response_meta::turn_state(response.headers()), @@ -164,7 +192,8 @@ pub(super) fn websocket_connection_metadata( diagnostics: response_meta::diagnostics( Some(response.status().as_u16()), response.headers(), - ), + ) + .with_response_origin(websocket_response_origin(connection)), } } @@ -208,24 +237,32 @@ async fn connect_websocket( Ok(None) ) { - let (websocket, response) = - connect_async_tls_with_config(request, Some(websocket_config()), false, connector) - .await?; + let (websocket, response) = connect_async_tls_with_config( + request, + Some(websocket_config(connection)), + false, + connector, + ) + .await?; return Ok((Box::new(websocket) as RawWsStream, response)); } let stream = dial_account(connection).await?; match stream { MaybeTlsStream::Plain(tcp) => { - let (websocket, response) = - client_async_tls_with_config(request, tcp, Some(websocket_config()), connector) - .await?; + let (websocket, response) = client_async_tls_with_config( + request, + tcp, + Some(websocket_config(connection)), + connector, + ) + .await?; Ok((Box::new(websocket) as RawWsStream, response)) } stream => { let (websocket, response) = client_async_tls_with_config( request, stream, - Some(websocket_config()), + Some(websocket_config(connection)), connector, ) .await?; @@ -239,7 +276,9 @@ async fn connect_websocket( })?; match result { Ok((websocket, response)) => Ok((websocket, response)), - Err(tungstenite::Error::Http(response)) => Err(websocket_opening_error(response.as_ref())), + Err(tungstenite::Error::Http(response)) => { + Err(websocket_opening_error(response.as_ref(), connection)) + } Err(error) => Err(CodexWebSocketExchangeError::Connect(error)), } } @@ -345,9 +384,9 @@ fn websocket_handshake_request( builder.body(()) } -fn websocket_config() -> WebSocketConfig { +fn websocket_config(connection: &CodexWebSocketConnection) -> WebSocketConfig { let mut extensions = ExtensionsConfig::default(); - extensions.permessage_deflate = Some(DeflateConfig::default()); + extensions.permessage_deflate = connection.permessage_deflate.then(DeflateConfig::default); let mut config = WebSocketConfig::default(); // 上游 Responses 事件属于 Codex 协议数据,不能沿用 tungstenite 的私有 @@ -358,7 +397,10 @@ fn websocket_config() -> WebSocketConfig { config } -fn websocket_opening_error(response: &WsResponse>>) -> CodexWebSocketExchangeError { +fn websocket_opening_error( + response: &WsResponse>>, + connection: &CodexWebSocketConnection, +) -> CodexWebSocketExchangeError { let status_code = response.status().as_u16(); let raw_body = response .body() @@ -391,11 +433,22 @@ fn websocket_opening_error(response: &WsResponse>>) -> CodexWebSo )) }), response_meta::set_cookie_headers(response.headers()), - response_meta::diagnostics(Some(status_code), response.headers()), + response_meta::diagnostics(Some(status_code), response.headers()) + .with_response_origin(websocket_response_origin(connection)), CodexUpstreamSendPhase::BeforePayload, ) } +fn websocket_response_origin(connection: &CodexWebSocketConnection) -> Option { + reqwest::Url::parse( + &connection + .endpoint + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1), + ) + .ok() +} + fn websocket_host_header(endpoint: &str) -> Option { let url = reqwest::Url::parse(endpoint).ok()?; let host = url.host_str()?; diff --git a/backend/crates/providers/openai/src/transport/websocket/live.rs b/backend/crates/providers/openai/src/transport/websocket/live.rs index 66708449d..efc826c58 100644 --- a/backend/crates/providers/openai/src/transport/websocket/live.rs +++ b/backend/crates/providers/openai/src/transport/websocket/live.rs @@ -50,6 +50,7 @@ pub(crate) async fn connect_live_sideband( let connection = CodexWebSocketConnection { endpoint, headers: business_headers, + permessage_deflate: true, outbound_proxy, connection_budget: None, }; diff --git a/backend/crates/providers/openai/src/transport/websocket/model.rs b/backend/crates/providers/openai/src/transport/websocket/model.rs index e23f174c4..c4a78799f 100644 --- a/backend/crates/providers/openai/src/transport/websocket/model.rs +++ b/backend/crates/providers/openai/src/transport/websocket/model.rs @@ -7,12 +7,24 @@ use crate::transport::protocol::responses::{ }; /// WebSocket opening 描述 -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct CodexWebSocketConnection { pub(crate) connection_budget: Option, pub(crate) outbound_proxy: Option, pub(super) endpoint: String, pub(super) headers: Vec<(String, String)>, + pub(super) permessage_deflate: bool, +} + +impl fmt::Debug for CodexWebSocketConnection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CodexWebSocketConnection") + .field("endpoint", &"") + .field("header_count", &self.headers.len()) + .field("permessage_deflate", &self.permessage_deflate) + .finish_non_exhaustive() + } } // 请求预算和代理额度不是连接身份,额度调整不能改变已有连接的复用合同 @@ -20,6 +32,7 @@ impl PartialEq for CodexWebSocketConnection { fn eq(&self, other: &Self) -> bool { self.endpoint == other.endpoint && self.headers == other.headers + && self.permessage_deflate == other.permessage_deflate && self .outbound_proxy .as_ref() @@ -110,6 +123,21 @@ impl fmt::Display for PreviousResponseUnavailableReason { } impl CodexWebSocketRequest { + pub(crate) fn take_payload_text(&mut self) -> String { + std::mem::take(&mut self.payload_text) + } + + pub(crate) fn replace_outbound( + &mut self, + endpoint: String, + headers: Vec<(String, String)>, + payload: String, + ) { + self.connection.endpoint = endpoint; + self.connection.headers = headers; + self.payload_text = payload; + } + /// 返回连接描述 pub fn connection(&self) -> &CodexWebSocketConnection { &self.connection @@ -132,6 +160,7 @@ impl CodexWebSocketConnection { Self { endpoint: endpoint.into(), headers, + permessage_deflate: true, outbound_proxy: None, connection_budget: None, } diff --git a/backend/crates/providers/openai/tests/provider/contract/cookies.rs b/backend/crates/providers/openai/tests/provider/contract/cookies.rs index 3cec69e12..f82dcfce5 100644 --- a/backend/crates/providers/openai/tests/provider/contract/cookies.rs +++ b/backend/crates/providers/openai/tests/provider/contract/cookies.rs @@ -50,6 +50,199 @@ fn cookie_provider(store: &Arc, base_url: String) -> Arc); + +impl MiddlewarePlan for ParametersMiddleware { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Attempt); + assert!(request.provider_parameters().unwrap()["installation_id"].is_string()); + next.run(request.with_provider_parameters(self.0.clone())) + } +} + +#[tokio::test] +async fn request_parameters_drive_native_identity_and_derivation_across_http_and_websocket() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let identities = [ + "f77b9b8e-563a-4d84-a66f-f2a0c98d5a8e", + "f6e974b5-708d-4940-b619-1ab2c58c2f2c", + ]; + let mut captures = Vec::new(); + for identity in identities { + for websocket in [false, true] { + let (base_url, capture): ( + String, + BoxFuture<'static, (reqwest::header::HeaderMap, Value)>, + ) = if websocket { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let mut headers = None; + let mut socket = + crate::transport::accept_codex_test_websocket_with(socket, |request, _| { + headers = Some(request.headers().clone()) + }) + .await; + let frame = socket.next().await.unwrap().unwrap(); + let body = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + socket.send(Message::Text(json!({"type":"response.completed", "response":{ + "id":"resp_parameters", "model":"gpt-5.4", "status":"completed", "output":[] + }}).to_string().into())).await.unwrap(); + (headers.unwrap(), body) + }); + (url, Box::pin(async { task.await.unwrap() })) + } else { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + ( + server.uri(), + Box::pin(async move { + let requests = server.received_requests().await.unwrap(); + ( + requests[0].headers.clone(), + captured_request_body(&requests[0]), + ) + }), + ) + }; + let payload = payload(websocket); + let mut request_context = payload.context().clone(); + let mut turn_metadata = metadata(); + turn_metadata["installation_id"] = json!("client-installation"); + request_context.insert("turn_metadata".into(), json!(turn_metadata.to_string())); + let mut stream = provider_with_base_url(&store, base_url) + .execute( + planned_request( + "openai", + Operation::Generate(GenerateRequest::from_protocol_payload( + payload.with_context(request_context), + )), + ), + context_with_middleware( + "req_parameters", + Arc::new(ParametersMiddleware(Map::from_iter([( + "installation_id".into(), + json!(identity), + )]))), + FastMode::Default, + ), + ) + .await + .unwrap(); + assert_eq!( + stream.metadata().provider_account_id().as_str(), + "acct_provider_contract" + ); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let (headers, body) = capture.await; + assert_eq!(body["client_metadata"]["x-codex-installation-id"], identity); + let metadata: Value = + serde_json::from_str(headers["x-codex-turn-metadata"].to_str().unwrap()).unwrap(); + assert_eq!(metadata["installation_id"], identity); + assert_eq!( + headers["session-id"].to_str().unwrap(), + body["client_metadata"]["session_id"] + ); + captures.push(( + body["client_metadata"]["session_id"].clone(), + body["prompt_cache_key"].clone(), + )); + } + } + assert_eq!(captures[0], captures[1]); + assert_eq!(captures[2], captures[3]); + assert_ne!(captures[0].0, captures[2].0); + assert_ne!(captures[0].1, captures[2].1); +} + +#[tokio::test] +async fn request_parameters_partition_new_websocket_chains_without_preventing_reuse() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let identities = ["parameters-first", "parameters-second"]; + let server = tokio::spawn(async move { + let mut retained = Vec::new(); + for (index, identity) in identities.into_iter().enumerate() { + let (socket, _) = tokio::time::timeout(Duration::from_secs(3), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut socket = + crate::transport::accept_codex_test_websocket_with(socket, |request, _| { + let metadata: Value = serde_json::from_str( + request.headers()["x-codex-turn-metadata"].to_str().unwrap(), + ) + .unwrap(); + assert_eq!(metadata["installation_id"], identity); + }) + .await; + for turn in 0..=index { + let text = tokio::time::timeout(Duration::from_secs(3), async { + loop { + match socket.next().await.unwrap().unwrap() { + Message::Text(text) => break text, + Message::Ping(payload) => { + socket.send(Message::Pong(payload)).await.unwrap() + } + message => panic!("unexpected websocket frame: {message:?}"), + } + } + }) + .await + .unwrap(); + let body: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(body["client_metadata"]["x-codex-installation-id"], identity); + socket.send(Message::Text(json!({"type":"response.completed", "response":{ + "id":format!("resp_parameters_{index}_{turn}"), "model":"gpt-5.4", "status":"completed", "output":[] + }}).to_string().into())).await.unwrap(); + } + // 保持旧连接可复用,证明新身份的隔离不是由测试服务端断开触发 + retained.push(socket); + } + }); + let provider = provider_with_base_url(&store, url); + for identity in [identities[0], identities[1], identities[1]] { + let payload = payload(true); + let mut request_context = payload.context().clone(); + request_context.insert( + "turn_metadata".into(), + json!(json!({"installation_id":"client-installation"}).to_string()), + ); + let mut stream = Arc::clone(&provider) + .execute( + planned_request( + "openai", + Operation::Generate( + GenerateRequest::from_protocol_payload( + payload.with_context(request_context), + ) + .with_provider_session_state( + ProviderSessionState::new( + "openai", + Map::from_iter([ + ("account_id".into(), json!("acct_provider_contract")), + ("conversation_id".into(), json!("parameters-conversation")), + ("continuation_scope".into(), json!("persisted")), + ]), + ) + .unwrap(), + ), + ), + ), + context_with_middleware( + "req_parameters_pool", + Arc::new(ParametersMiddleware(Map::from_iter([( + "installation_id".into(), + json!(identity), + )]))), + FastMode::Default, + ), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while let Some(event) = stream.next().await { + event.unwrap(); + } + }) + .await + .unwrap(); + } + server.await.unwrap(); +} + +#[tokio::test] +async fn request_parameters_apply_to_images_and_search_without_reencoding_opaque_bodies() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]}))) + .expect(3) + .mount(&server) + .await; + let provider = provider_with_base_url(&store, server.uri()); + let body = Bytes::from_static(br#"{ "future":1, "future":2 }"#); + let raw = RawJsonPayload::new("openai", body.clone()) + .unwrap() + .with_context(Map::from_iter([( + "turn_metadata".into(), + json!(json!({"installation_id":"client-installation"}).to_string()), + )])); + for operation in [ + Operation::GenerateImage(ImageRequest::from_raw_json( + ImageRequestKind::Generation, + raw.clone(), + )), + Operation::GenerateImage(ImageRequest::from_raw_json( + ImageRequestKind::Edit, + raw.clone(), + )), + Operation::Search(StandaloneSearchRequest::from_raw_json(raw)), + ] { + let mut stream = Arc::clone(&provider) + .execute( + planned_provider_endpoint_request("openai", operation), + context_with_middleware( + "req_parameters_json", + Arc::new(ParametersMiddleware(Map::from_iter([( + "installation_id".into(), + json!("plugin-installation"), + )]))), + FastMode::Default, + ), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } + for request in server.received_requests().await.unwrap() { + assert_eq!(request.body.as_slice(), body.as_ref()); + let metadata: Value = + serde_json::from_str(request.headers["x-codex-turn-metadata"].to_str().unwrap()) + .unwrap(); + assert_eq!(metadata["installation_id"], "plugin-installation"); + } +} + +#[tokio::test] +async fn invalid_request_parameters_fail_without_account_identity_fallback_or_send() { + 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()); + for parameters in [ + json!({}), + json!({"installation_id":null}), + json!({"installation_id":42}), + ] { + let error = provider + .clone() + .execute( + planned_request("openai", http_generate_operation()), + context_with_middleware( + "req_invalid_parameters", + Arc::new(ParametersMiddleware( + parameters.as_object().unwrap().clone(), + )), + FastMode::Default, + ), + ) + .await + .err() + .expect("invalid parameters must fail"); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); + } + assert!(server.received_requests().await.unwrap().is_empty()); + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + let mut stream = provider + .execute( + planned_request("openai", http_generate_operation()), + context("req_after_invalid_parameters", CancellationToken::new()), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } +} + fn metadata() -> Value { json!({"session_id":"source-session", "thread_id":"source-thread", "turn_id":"source-turn", "parent_thread_id":"source-session", "forked_from_thread_id":"source-session", diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index d6b86a591..9882433fb 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -8,6 +8,8 @@ mod error_details; mod image_account_eligibility; mod image_model_access; mod model_account_eligibility; +mod outbound; +pub(super) use outbound::outbound_context; mod passthrough; mod precommit; mod privacy; @@ -1552,12 +1554,17 @@ impl MiddlewarePlan for TranslationMiddleware { assert_eq!(context.mount(), MiddlewareMount::Attempt); assert!(context.account_id().is_some()); *self.translations.lock().unwrap() += 1; - let (_, headers, _) = request.into_parts(); - next.run(MiddlewareRequest::new( - "openai", - headers, - Bytes::from(serde_json::to_vec(&self.target).unwrap()), - )) + let headers = request.headers().to_vec(); + next.run( + request + .replace_parts( + "openai".into(), + headers, + Bytes::from(serde_json::to_vec(&self.target).unwrap()), + None, + ) + .unwrap(), + ) } } @@ -1591,16 +1598,21 @@ impl MiddlewarePlan for RecordingMiddleware { ) -> BoxFuture<'static, Result> { assert_eq!(context.mount(), MiddlewareMount::Attempt); assert_eq!(request.protocol(), "openai"); - let (protocol, mut headers, bytes) = request.into_parts(); + let (protocol, mut headers, bytes) = request.clone().into_parts(); let mut body: Map = serde_json::from_slice(&bytes).unwrap(); self.observed.lock().unwrap().push(body.clone()); body.insert(self.replacement.0.clone(), self.replacement.1.clone()); headers.extend(self.request_headers.clone()); - next.run(MiddlewareRequest::new( - protocol, - headers, - Bytes::from(serde_json::to_vec(&body).unwrap()), - )) + next.run( + request + .replace_parts( + protocol, + headers, + Bytes::from(serde_json::to_vec(&body).unwrap()), + None, + ) + .unwrap(), + ) } } diff --git a/backend/crates/providers/openai/tests/provider/contract/outbound.rs b/backend/crates/providers/openai/tests/provider/contract/outbound.rs new file mode 100644 index 000000000..8ce4c7bd8 --- /dev/null +++ b/backend/crates/providers/openai/tests/provider/contract/outbound.rs @@ -0,0 +1,506 @@ +//! 最终出站准备覆盖真实字节、目标以及原生连接归属 + +use super::*; +use crate::transport::accept_codex_test_websocket_with; +use gateway_core::engine::middleware::outbound::{self, Request, Target}; + +type Rewrite = dyn Fn(&mut Request) -> Result<(), MiddlewareError> + Send + Sync; + +struct OutboundMiddleware(Arc); + +impl std::fmt::Debug for OutboundMiddleware { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("OutboundMiddleware") + } +} + +impl MiddlewarePlan for OutboundMiddleware { + fn has_outbound(&self, _context: &MiddlewareContext) -> bool { + true + } + + fn handle_outbound( + &self, + context: MiddlewareContext, + mut request: Request, + next: outbound::Next, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Outbound); + let result = (self.0)(&mut request); + Box::pin(async move { + result?; + next.run(request).await + }) + } + + fn handle( + &self, + _context: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + next.run(request) + } +} + +pub(in crate::provider) fn outbound_context( + request_id: &str, + rewrite: impl Fn(&mut Request) -> Result<(), MiddlewareError> + Send + Sync + 'static, +) -> AttemptContext { + context_with_middleware( + request_id, + Arc::new(OutboundMiddleware(Arc::new(rewrite))), + FastMode::Default, + ) +} + +#[tokio::test] +async fn outbound_http_sends_final_encoded_bytes_and_target_without_restoring_defaults() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let original = MockServer::start().await; + let target = MockServer::start().await; + Mock::given(method("PATCH")) + .and(path("/plugin-target")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&target) + .await; + let uri = format!("{}/plugin-target?source=outbound", target.uri()); + let mut stream = provider_with_base_url(&store, original.uri()).execute( + planned_request("openai", http_generate_operation()), + outbound_context("req_outbound_http", move |request| { + assert!(matches!(&request.target, Target::Http { uri, .. } if uri.path() == "/codex/responses")); + let headers = request.header_map().unwrap(); + assert!(headers.contains_key("user-agent")); + assert_eq!(headers["content-encoding"], "zstd"); + let body = zstd::decode_all(request.body.as_ref()).unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap()["stream"], true); + request.target = Target::Http { method: reqwest::Method::PATCH, uri: uri.parse().unwrap() }; + request.headers.retain(|header| !matches!(header.name(), "user-agent" | "content-encoding" | "content-type" | "authorization" | "accept")); + request.headers.extend([ + MiddlewareHeader::new("x-plugin-values", Bytes::from_static(b"first")), + MiddlewareHeader::new("x-plugin-values", Bytes::from_static(b"second")), + MiddlewareHeader::new("x-plugin-bytes", Bytes::from_static(b"\x80")), + ]); + request.body = Bytes::from_static(b"\xff\0opaque outbound body"); + Ok(()) + }), + ).await.unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + assert!(original.received_requests().await.unwrap().is_empty()); + let requests = target.received_requests().await.unwrap(); + let request = &requests[0]; + assert_eq!(request.body, b"\xff\0opaque outbound body"); + for name in [ + "user-agent", + "content-encoding", + "content-type", + "authorization", + ] { + assert!( + !request.headers.contains_key(name), + "removed header restored: {name}" + ); + } + // 底层 reqwest 保留自身缺省 Accept,出站准备不承诺禁用该行为 + assert_eq!(request.headers["accept"], "*/*"); + assert_eq!( + request + .headers + .get_all("x-plugin-values") + .iter() + .map(|v| v.to_str().unwrap()) + .collect::>(), + ["first", "second"] + ); + assert_eq!(request.url.query(), Some("source=outbound")); + assert_eq!(request.headers["x-plugin-bytes"].as_bytes(), b"\x80"); +} + +#[tokio::test] +async fn outbound_images_and_search_send_opaque_replacements() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("PUT")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]}))) + .expect(3) + .mount(&server) + .await; + let provider = provider_with_base_url(&store, server.uri()); + let raw = RawJsonPayload::new( + "openai", + Bytes::from_static(br#"{ "future":1, "future":2 }"#), + ) + .unwrap(); + for operation in [ + Operation::GenerateImage(ImageRequest::from_raw_json( + ImageRequestKind::Generation, + raw.clone(), + )), + Operation::GenerateImage(ImageRequest::from_raw_json( + ImageRequestKind::Edit, + raw.clone(), + )), + Operation::Search(StandaloneSearchRequest::from_raw_json(raw)), + ] { + let mut stream = provider + .clone() + .execute( + planned_provider_endpoint_request("openai", operation), + outbound_context("req_outbound_json", |request| { + assert_eq!(request.body.as_ref(), br#"{ "future":1, "future":2 }"#); + assert!(!request.header_map().unwrap().contains_key("accept")); + let Target::Http { method, .. } = &mut request.target else { + panic!("HTTP target required") + }; + *method = reqwest::Method::PUT; + request + .headers + .retain(|header| !matches!(header.name(), "user-agent" | "accept")); + request.body = Bytes::from_static(b"\xffendpoint bytes"); + Ok(()) + }), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } + let requests = server.received_requests().await.unwrap(); + assert_eq!( + requests + .iter() + .map(|r| r.url.path()) + .collect::>(), + BTreeSet::from([ + "/codex/images/generations", + "/codex/images/edits", + "/codex/alpha/search" + ]) + ); + for request in requests { + assert_eq!(request.body, b"\xffendpoint bytes"); + assert!(!request.headers.contains_key("user-agent")); + assert_eq!(request.headers["accept"], "*/*"); + } +} + +#[tokio::test] +async fn outbound_invalid_and_faulting_websocket_preparation_never_opens_or_falls_back() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let provider = + provider_with_base_url(&store, format!("http://{}", listener.local_addr().unwrap())); + for case in 0..6 { + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"gpt-5.4", "input":"hello"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".into(), json!(true))])), + )); + let mut stream = provider + .clone() + .execute( + planned_request("openai", operation), + outbound_context("req_outbound_invalid", move |request| { + assert!(matches!(request.target, Target::WebSocket { .. })); + match case { + 0 => return Err(MiddlewareError::Fault), + 1 => request.body = Bytes::from_static(b"\xff"), + 2 => request.headers.push(MiddlewareHeader::new( + "x-invalid", + Bytes::from_static(b"bad\nheader"), + )), + 3 => request.headers.retain(|header| { + !header.name().eq_ignore_ascii_case("sec-websocket-key") + }), + 4 => request.headers.push(MiddlewareHeader::new( + "sec-websocket-extensions", + Bytes::from_static(b"unknown-extension"), + )), + 5 => request.headers.push(MiddlewareHeader::new( + "x-non-ascii", + Bytes::from_static(b"\x80"), + )), + _ => unreachable!(), + } + Ok(()) + }), + ) + .await + .unwrap(); + let mut failure = None; + timeout(Duration::from_secs(2), async { + while let Some(event) = stream.next().await { + if let Err(error) = event { + failure = Some(error); + break; + } + } + }) + .await + .unwrap(); + let failure = failure.expect("preparation must fail"); + assert_eq!(failure.send_state(), UpstreamSendState::NotSent); + assert!(failure.retry_is_prohibited()); + if case != 0 { + assert_eq!(failure.kind(), ProviderErrorKind::InvalidRequest); + } + } + assert!( + timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err(), + "preparation failure must not dial or fall back" + ); +} + +#[tokio::test] +async fn outbound_websocket_keeps_exact_chains_and_reuses_only_matching_final_openings() { + const ACCOUNT: &str = "acct_provider_contract"; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, ACCOUNT).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut first = accept_codex_test_websocket_with(stream, |request, _response| { + assert_eq!(request.uri().path(), "/opening-a"); + assert!(!request.headers().contains_key("user-agent")); + assert!(!request.headers().contains_key("sec-websocket-extensions")); + assert_eq!( + request + .headers() + .get_all("x-plugin-profile") + .iter() + .map(|v| v.to_str().unwrap()) + .collect::>(), + ["a", "shared"] + ); + }) + .await; + for (index, response_id) in ["resp_outbound_a", "resp_outbound_exact"] + .into_iter() + .enumerate() + { + let text = timeout(Duration::from_secs(5), async { + loop { + match first.next().await.unwrap().unwrap() { + Message::Text(text) => break text, + Message::Ping(payload) => first.send(Message::Pong(payload)).await.unwrap(), + message => panic!("unexpected frame: {message:?}"), + } + } + }) + .await + .unwrap(); + let body: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(body["plugin_turn"], index); + if index == 1 { + assert_eq!(body["previous_response_id"], "plugin-forged-reference"); + } + first.send(Message::Text(json!({"type":"response.completed", "response":{"id":response_id,"model":"gpt-5.4","status":"completed","output":[],"store":false}}).to_string().into())).await.unwrap(); + } + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut second = accept_codex_test_websocket_with(stream, |request, _response| { + assert_eq!(request.uri().path(), "/opening-b"); + assert!(!request.headers().contains_key("sec-websocket-extensions")); + assert_eq!( + request + .headers() + .get_all("x-plugin-profile") + .iter() + .map(|v| v.to_str().unwrap()) + .collect::>(), + ["b", "shared"] + ); + }) + .await; + for index in 2..4 { + let text = timeout(Duration::from_secs(5), async { + loop { + match second.next().await.unwrap().unwrap() { + Message::Text(text) => break text, + Message::Ping(payload) => { + second.send(Message::Pong(payload)).await.unwrap() + } + message => panic!("unexpected frame: {message:?}"), + } + } + }) + .await + .unwrap(); + if index == 3 { + assert_eq!(text.as_str(), "opaque text from plugin"); + } else { + assert_eq!( + serde_json::from_str::(&text).unwrap()["plugin_turn"], + index + ); + } + second.send(Message::Text(json!({"type":"response.completed", "response":{"id":format!("resp_outbound_{index}"),"model":"gpt-5.4","status":"completed","output":[],"store":false}}).to_string().into())).await.unwrap(); + } + // 第一条连接直到新 profile 完成仍保持活动,排除断开触发重新拨号的假阳性 + drop(first); + }); + let provider = provider_with_base_url(&store, format!("http://{address}")); + for index in 0..4 { + let previous = (index == 1).then_some("resp_outbound_a"); + let mut body = json!({"model":"gpt-5.4","input":"hello","store":false}); + if let Some(previous) = previous { + body["previous_response_id"] = json!(previous); + } + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap().with_context(Map::from_iter([ + ("use_websocket".into(), json!(true)), + ("downstream_websocket_connection_id".into(), json!("outbound-downstream")), + ])), + ).with_provider_session_state(ProviderSessionState::new("openai", json!({"account_id":ACCOUNT,"conversation_id":"outbound-conversation","continuation_scope":"connection_local"}).as_object().unwrap().clone()).unwrap())); + if index == 1 { + let mut failed = provider + .clone() + .execute( + planned_request("openai", operation.clone()), + outbound_context("req_outbound_pool_fault", |_| Err(MiddlewareError::Fault)), + ) + .await + .unwrap(); + let mut error = None; + while let Some(event) = failed.next().await { + if let Err(failure) = event { + error = Some(failure); + break; + } + } + assert_eq!(error.unwrap().send_state(), UpstreamSendState::NotSent); + } + let middleware = FrozenMiddlewarePlan::new( + Arc::new(OutboundMiddleware(Arc::new(move |request| { + assert!( + request + .header_map() + .unwrap() + .contains_key("sec-websocket-key") + ); + let Target::WebSocket { uri } = &mut request.target else { + panic!("WebSocket target") + }; + *uri = match index { + 0 => format!("ws://{address}/opening-a"), + 1 => "ws://127.0.0.1:1/must-not-open".to_owned(), + _ => format!("ws://{address}/opening-b"), + } + .parse() + .unwrap(); + request.headers.retain(|header| { + !["user-agent", "sec-websocket-extensions"] + .iter() + .any(|name| header.name().eq_ignore_ascii_case(name)) + }); + request.headers.push(MiddlewareHeader::new( + "x-plugin-profile", + Bytes::from_static(if index == 0 { b"a" } else { b"b" }), + )); + request.headers.push(MiddlewareHeader::new( + "x-plugin-profile", + Bytes::from_static(b"shared"), + )); + let mut body: Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(body["type"], "response.create"); + body["plugin_turn"] = json!(index); + if index == 1 { + body["previous_response_id"] = json!("plugin-forged-reference"); + } + request.body = if index == 3 { + Bytes::from_static(b"opaque text from plugin") + } else { + serde_json::to_vec(&body).unwrap().into() + }; + Ok(()) + }))), + ExtensionSetReference::new( + ExtensionSetId::new(format!("outbound-{index}")).unwrap(), + Arc::new(TestExtensionLease), + ), + ); + let provider_kind = ProviderKind::new("openai").unwrap(); + let account = ProviderAccountId::new(ACCOUNT).unwrap(); + let client = ClientApiKeyId::new("key_openai_contract").unwrap(); + let binding = previous.map(|previous| { + ContinuationBinding::Pinned( + NativeContinuationPin::new( + PreviousResponseId::new(previous), + PreviousResponseId::new(previous), + client.clone(), + provider_kind.clone(), + account.clone(), + ) + .with_scope( + gateway_core::engine::continuation::NativeContinuationScope::ConnectionLocal, + ), + ) + }); + let attempt = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new(format!("req_outbound_ws_{index}")).unwrap(), + client, + ) + .with_middleware( + Some(middleware), + Arc::from([]), + "/v1/responses".into(), + ClientTransport::HttpSse, + ), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(10), + account_policy(), + AccountAttemptContext::new( + BTreeSet::new(), + None, + Some(ProviderAccountStateOwner::new(provider_kind, account)), + ) + .with_account_scope(contract_account_scope()), + binding, + CancellationToken::new(), + ) + .with_continuation_attempt(if previous.is_some() { + ContinuationAttempt::Native + } else { + ContinuationAttempt::None + }); + let mut stream = provider + .clone() + .execute(planned_request("openai", operation), attempt) + .await + .unwrap(); + timeout(Duration::from_secs(10), async { + while let Some(event) = stream.next().await { + event.unwrap(); + } + }) + .await + .unwrap(); + } + timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); +} diff --git a/backend/crates/providers/openai/tests/provider/contract/session_binding.rs b/backend/crates/providers/openai/tests/provider/contract/session_binding.rs index 4fa3aed5a..416b6f159 100644 --- a/backend/crates/providers/openai/tests/provider/contract/session_binding.rs +++ b/backend/crates/providers/openai/tests/provider/contract/session_binding.rs @@ -140,12 +140,14 @@ impl MiddlewarePlan for ChangeTurnMetadata { ) -> BoxFuture<'static, Result> { let metadata = self.0; Box::pin(async move { - let (protocol, mut headers, body) = request.into_parts(); + let protocol = request.protocol().to_owned(); + let mut headers = request.headers().to_vec(); + let body = request.body().clone(); headers.push(MiddlewareHeader::new( "x-codex-turn-metadata", Bytes::from_static(metadata), )); - next.run(MiddlewareRequest::new(protocol, headers, body)) + next.run(request.replace_parts(protocol, headers, body, None)?) .await }) } diff --git a/backend/crates/providers/openai/tests/provider/live.rs b/backend/crates/providers/openai/tests/provider/live.rs index 413377d3e..debd6f08d 100644 --- a/backend/crates/providers/openai/tests/provider/live.rs +++ b/backend/crates/providers/openai/tests/provider/live.rs @@ -34,6 +34,56 @@ const MODEL: &str = "gpt-live-1-codex"; const ACCOUNT: &str = "acct_provider_contract"; const CALL: &str = "call_live_regression"; +#[tokio::test] +async fn outbound_live_create_sends_final_body_headers_method_and_target() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, ACCOUNT).await; + let original = MockServer::start().await; + let target = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/plugin-live")) + .respond_with( + ResponseTemplate::new(201) + .insert_header("location", format!("/v1/live/{CALL}")) + .insert_header("content-type", "application/sdp") + .set_body_string("v=0\r\n"), + ) + .expect(1) + .mount(&target) + .await; + let uri = format!("{}/plugin-live", target.uri()); + let mut stream = provider_with_base_url(&store, original.uri()) + .execute( + live_request(), + super::contract::outbound_context("req_outbound_live", move |request| { + assert_eq!( + serde_json::from_slice::(&request.body).unwrap()["sdp"], + "v=0" + ); + assert!(!request.header_map().unwrap().contains_key("accept")); + request.target = gateway_core::engine::middleware::outbound::Target::Http { + method: reqwest::Method::PUT, + uri: uri.parse().unwrap(), + }; + request + .headers + .retain(|header| !matches!(header.name(), "user-agent" | "accept")); + request.body = Bytes::from_static(b"\xffopaque live request"); + Ok(()) + }), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + assert!(original.received_requests().await.unwrap().is_empty()); + let requests = target.received_requests().await.unwrap(); + assert_eq!(requests[0].body, b"\xffopaque live request"); + assert!(!requests[0].headers.contains_key("user-agent")); + assert_eq!(requests[0].headers["accept"], "*/*"); +} + fn scope(model_access: AccountModelAccess) -> Arc { Arc::new(FrozenAccountScope::new( Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([( @@ -152,6 +202,73 @@ async fn live_bootstrap_preserves_model_through_middleware_and_metadata() { upstream.verify().await; } +#[derive(Debug)] +struct ParametersMiddleware; + +impl MiddlewarePlan for ParametersMiddleware { + fn handle( + &self, + _context: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + assert!(request.provider_parameters().unwrap()["installation_id"].is_string()); + next.run( + request.with_provider_parameters(serde_json::Map::from_iter([( + "installation_id".into(), + json!("live-plugin-installation"), + )])), + ) + } +} + +#[tokio::test] +async fn live_create_uses_request_parameters_without_changing_call_ownership() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, ACCOUNT).await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/realtime/calls")) + .respond_with( + ResponseTemplate::new(201) + .insert_header("location", format!("/v1/live/{CALL}")) + .insert_header("content-type", "application/sdp") + .set_body_string("v=0\r\n"), + ) + .expect(1) + .mount(&server) + .await; + let provider = crate::admin::initialized_test_provider(store, server.uri()).await; + let request = + live_request_with_headers(vec![gateway_core::operation::ProviderHttpHeader::new( + "x-codex-turn-metadata", + Bytes::from(json!({"installation_id":"client-installation"}).to_string()), + )]); + let mut stream = provider + .execute( + request, + context_with_middleware( + "req_live_parameters", + Arc::new(ParametersMiddleware), + FastMode::Default, + ), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + assert_eq!(stream.metadata().provider_account_id().as_str(), ACCOUNT); + let requests = server.received_requests().await.unwrap(); + let metadata: serde_json::Value = serde_json::from_str( + requests[0].headers["x-codex-turn-metadata"] + .to_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(metadata["installation_id"], "live-plugin-installation"); +} + #[tokio::test] async fn live_sideband_rejects_revoked_scope_and_model_without_claiming_call() { let (provider, _store, _upstream) = bootstrap().await; diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index 511d37528..4f7209b1b 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -72,7 +72,7 @@ mod usage; mod websocket; mod websocket_pool; -fn websocket_accept_config() -> WebSocketConfig { +pub(crate) fn websocket_accept_config() -> WebSocketConfig { let mut extensions = ExtensionsConfig::default(); extensions.permessage_deflate = Some(DeflateConfig::default()); let mut config = WebSocketConfig::default(); diff --git a/backend/crates/providers/openai/tests/transport/protocol/websocket.rs b/backend/crates/providers/openai/tests/transport/protocol/websocket.rs index 17239b551..c013841d4 100644 --- a/backend/crates/providers/openai/tests/transport/protocol/websocket.rs +++ b/backend/crates/providers/openai/tests/transport/protocol/websocket.rs @@ -133,8 +133,11 @@ fn required_websocket_audit_should_forbid_http_fallback() { .clone(), ); let payload = websocket_payload_audit_snapshot(&request); - let artifact = - websocket_audit_artifact_from_attempt(&request, OpeningAuditSnapshot::default(), payload); + let artifact = websocket_audit_artifact_from_attempt( + &request, + OpeningAuditSnapshot::default(), + Some(payload), + ); assert_eq!(artifact.transport_mode, "explicit_websocket_warmup"); assert!(!artifact.fallback_allowed); @@ -326,7 +329,7 @@ fn websocket_audit_artifact_should_record_opening_and_redacted_payload() { }; let payload = websocket_payload_audit_snapshot(&request); - let artifact = websocket_audit_artifact_from_attempt(&request, opening.clone(), payload); + let artifact = websocket_audit_artifact_from_attempt(&request, opening.clone(), Some(payload)); assert_eq!(artifact.opening, Some(opening)); assert_eq!( diff --git a/backend/crates/providers/openai/tests/transport/websocket/mod.rs b/backend/crates/providers/openai/tests/transport/websocket/mod.rs index ddf5ed895..c77af8cb8 100644 --- a/backend/crates/providers/openai/tests/transport/websocket/mod.rs +++ b/backend/crates/providers/openai/tests/transport/websocket/mod.rs @@ -12,6 +12,22 @@ mod diagnostics; mod fingerprint; mod metadata; +#[test] +fn outbound_websocket_diagnostics_do_not_expose_target_credentials() { + let connection = CodexWebSocketConnection::new( + "wss://user:password@upstream.example/responses?access_token=secret", + vec![("authorization".into(), "Bearer credential".into())], + ); + assert_eq!( + connection.opening_audit_snapshot().request_line, + "GET /responses HTTP/1.1" + ); + let debug = format!("{connection:?}"); + for value in ["password", "access_token", "secret", "credential"] { + assert!(!debug.contains(value)); + } +} + fn rate_limit_event(used_percent: u64) -> String { json!({ "type": "codex.rate_limits", diff --git a/backend/crates/providers/xai/src/provider/failure.rs b/backend/crates/providers/xai/src/provider/failure.rs index a36dda68e..fb59e139b 100644 --- a/backend/crates/providers/xai/src/provider/failure.rs +++ b/backend/crates/providers/xai/src/provider/failure.rs @@ -547,6 +547,10 @@ pub(super) fn map_transport_error_with_state( kind, forced_send_state.unwrap_or_else(|| error.send_state()), ); + if kind == ProviderErrorKind::InvalidRequest && error.send_state() == UpstreamSendState::NotSent + { + mapped = mapped.with_retry_prohibited(); + } if let Some(status) = error.status() { mapped = mapped.with_status(status); if allow_explicit_replay diff --git a/backend/crates/providers/xai/src/provider/mod.rs b/backend/crates/providers/xai/src/provider/mod.rs index 21394882a..adebc33eb 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 outbound; mod stream; mod upstream_adapter; mod workers; @@ -295,7 +296,8 @@ impl GrokBuildProvider { provider_kind, Some(model), account_id, - Box::new(move |operation, middleware_headers| { + None, + Box::new(move |operation, middleware_headers, _| { Box::pin(async move { if let Some(adapter) = adapter { return provider.execute_upstream_adapter( diff --git a/backend/crates/providers/xai/src/provider/outbound.rs b/backend/crates/providers/xai/src/provider/outbound.rs new file mode 100644 index 000000000..41ba48de5 --- /dev/null +++ b/backend/crates/providers/xai/src/provider/outbound.rs @@ -0,0 +1,48 @@ +//! 原生编码完成后准备 xAI 请求,保留所选账号和出口绑定 + +use gateway_core::engine::middleware::outbound::{Request, Target}; + +use super::*; + +pub(super) async fn prepare_request( + request: GrokInferenceRequest, + context: &AttemptContext, + session: &SelectedGrokSession, + upstream_model: &UpstreamModelId, +) -> Result { + let Some(pipeline) = context.outbound_middleware( + ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| invalid_request())?, + Some(upstream_model.as_str().to_owned()), + session.account_id().clone(), + ) else { + return Ok(request); + }; + let prepared = pipeline + .prepare(Request { + target: Target::Http { + method: request.method().clone(), + uri: request + .endpoint() + .as_str() + .parse() + .map_err(|_| invalid_request())?, + }, + headers: request.headers().to_vec(), + body: bytes::Bytes::copy_from_slice(request.body()), + }) + .await?; + prepared.header_map()?; + let Target::Http { method, uri } = prepared.target else { + return Err(invalid_request()); + }; + let endpoint = Url::parse(&uri.to_string()).map_err(|_| invalid_request())?; + Ok(request.with_prepared(method, endpoint, prepared.headers, prepared.body)) +} + +fn invalid_request() -> ProviderError { + ProviderError::new( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + .with_retry_prohibited() +} diff --git a/backend/crates/providers/xai/src/provider/stream.rs b/backend/crates/providers/xai/src/provider/stream.rs index 125b48cc9..53664d298 100644 --- a/backend/crates/providers/xai/src/provider/stream.rs +++ b/backend/crates/providers/xai/src/provider/stream.rs @@ -176,6 +176,9 @@ pub(super) fn cold_compaction_http_sse_stream( body, session.binding().clone(), ).with_trace(context.trace()); + let inference_request = super::outbound::prepare_request( + inference_request, &context, &session, &upstream_model, + ).await?; if context.deadline().is_elapsed() { Err(mark_transient_compaction_failure(provider_error( ProviderErrorKind::Timeout, @@ -482,6 +485,9 @@ pub(super) fn cold_http_sse_stream( body, session.binding().clone(), ).with_trace(context.trace()); + let inference_request = super::outbound::prepare_request( + inference_request, &context, &session, &upstream_model, + ).await?; if context.deadline().is_elapsed() { Err(provider_error( ProviderErrorKind::Timeout, diff --git a/backend/crates/providers/xai/src/transport/inference.rs b/backend/crates/providers/xai/src/transport/inference.rs index 84a6d63d6..698d246ef 100644 --- a/backend/crates/providers/xai/src/transport/inference.rs +++ b/backend/crates/providers/xai/src/transport/inference.rs @@ -6,6 +6,7 @@ use std::pin::Pin; use std::time::Duration; use futures::Stream; +use gateway_core::engine::middleware::MiddlewareHeader; use gateway_core::error::{ ClientVisibleUpstreamError, ErrorSource, OpaqueUpstreamValue, ProviderDiagnostic, RawUpstreamError, @@ -20,8 +21,9 @@ use super::{GrokHeader, GrokSessionBinding}; /// 交给注入的 HTTP SSE transport 的自持有请求 pub struct GrokInferenceRequest { trace: gateway_core::diagnostics::TraceContext, + method: reqwest::Method, endpoint: Url, - headers: Vec, + headers: Vec, body: Zeroizing>, binding: GrokSessionBinding, } @@ -35,8 +37,17 @@ impl GrokInferenceRequest { ) -> Self { Self { trace: gateway_core::diagnostics::TraceContext::default(), + method: reqwest::Method::POST, endpoint, - headers, + headers: headers + .into_iter() + .map(|header| { + MiddlewareHeader::new( + header.name().to_owned(), + bytes::Bytes::copy_from_slice(header.value().expose().as_bytes()), + ) + }) + .collect(), body: Zeroizing::new(body), binding, } @@ -52,7 +63,13 @@ impl GrokInferenceRequest { &self.trace } - /// 返回严格限定的官方 Responses 端点 + /// 返回交给 transport 的最终方法 + #[must_use] + pub const fn method(&self) -> &reqwest::Method { + &self.method + } + + /// 返回交给 transport 的最终目标,仍受其端点策略约束 #[must_use] pub const fn endpoint(&self) -> &Url { &self.endpoint @@ -60,11 +77,11 @@ impl GrokInferenceRequest { /// 返回类型化 header;适配层不得记录敏感值 #[must_use] - pub fn headers(&self) -> &[GrokHeader] { + pub fn headers(&self) -> &[MiddlewareHeader] { &self.headers } - /// 返回序列化后的类型化 Responses body + /// 返回最终出站字节,不要求仍是 JSON #[must_use] pub fn body(&self) -> &[u8] { &self.body @@ -75,13 +92,28 @@ impl GrokInferenceRequest { pub const fn binding(&self) -> &GrokSessionBinding { &self.binding } + + pub(crate) fn with_prepared( + mut self, + method: reqwest::Method, + endpoint: Url, + headers: Vec, + body: bytes::Bytes, + ) -> Self { + self.method = method; + self.endpoint = endpoint; + self.headers = headers; + self.body = Zeroizing::new(body.into()); + self + } } impl fmt::Debug for GrokInferenceRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("GrokInferenceRequest") - .field("endpoint", &self.endpoint) + .field("method", &self.method) + .field("endpoint", &"[REDACTED]") .field("headers", &self.headers) .field( "body", diff --git a/backend/crates/providers/xai/src/transport/network.rs b/backend/crates/providers/xai/src/transport/network.rs index 0a52f5873..1e4e7f94b 100644 --- a/backend/crates/providers/xai/src/transport/network.rs +++ b/backend/crates/providers/xai/src/transport/network.rs @@ -394,7 +394,7 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport { Box::pin(async move { if !self.endpoint_policy.validate_inference(request.endpoint()) { return Err(GrokInferenceTransportError::new( - GrokInferenceTransportErrorKind::Protocol, + GrokInferenceTransportErrorKind::InvalidRequest, UpstreamSendState::NotSent, )); } @@ -402,12 +402,12 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport { trace.headers( "upstream.request.headers", serde_json::json!({ - "method": "POST", "endpoint": request.endpoint().path(), + "method": request.method().as_str(), "operation": "responses", }), request .headers() .iter() - .map(|header| (header.name(), header.value().expose().as_bytes())), + .map(|header| (header.name(), header.value().as_ref())), ); trace.capture("upstream.request.body", request.body()); let (client, client_cache_status) = self @@ -415,10 +415,10 @@ impl GrokInferenceTransport for ReqwestGrokInferenceTransport { .await .map_err(|error| *error)?; let mut builder = client - .post(request.endpoint().clone()) + .request(request.method().clone(), request.endpoint().clone()) .body(request.body().to_vec()); for header in request.headers() { - builder = builder.header(header.name(), header.value().expose()); + builder = builder.header(header.name(), header.value().as_ref()); } let dns_observer = Arc::new(RequestDnsObserver::default()); let headers_started_at = Instant::now(); diff --git a/backend/crates/providers/xai/tests/provider/contract/mod.rs b/backend/crates/providers/xai/tests/provider/contract/mod.rs index b5c9ecf42..41999e026 100644 --- a/backend/crates/providers/xai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/xai/tests/provider/contract/mod.rs @@ -1,5 +1,6 @@ //! 验证 xAI 原生执行的协议转换、账号选择与交付状态隔离 +mod outbound; mod replay_tool_names; mod scheduling; mod selection_retry; @@ -511,7 +512,7 @@ async fn attempt_middleware_runs_once_before_native_encoding() { .headers() .iter() .filter(|header| header.name() == "x-business-context") - .map(|header| header.value().expose()) + .map(|header| std::str::from_utf8(header.value()).unwrap()) .collect::>(), vec!["tenant-public", "trace-public"], ); @@ -542,7 +543,7 @@ async fn attempt_middleware_runs_once_before_native_encoding() { .headers() .iter() .filter(|header| header.name().eq_ignore_ascii_case("authorization")) - .map(|header| header.value().expose()) + .map(|header| std::str::from_utf8(header.value()).unwrap()) .collect::>(); assert_eq!(values, ["Bearer plugin-value"]); assert!(!format!("{:?}", requests[0].headers()).contains("Bearer plugin-value")); @@ -1296,7 +1297,7 @@ async fn provider_with_recovery( async fn provider_with_catalog_transport( selector: Arc, - transport: Arc, + transport: Arc, recovery: Arc, catalog_transport: Arc, ) -> Arc { @@ -2468,7 +2469,7 @@ async fn compaction_should_pin_recorded_account_and_forward_session_headers() { .headers() .iter() .find(|header| header.name().eq_ignore_ascii_case(name)) - .map(|header| header.value().expose()) + .map(|header| std::str::from_utf8(header.value()).unwrap()) }; assert_eq!(header("x-grok-conv-id"), Some("cache-session")); assert_eq!(header("x-grok-session-id"), Some("cache-session")); @@ -4380,12 +4381,14 @@ async fn configured_request_profile_reaches_inference_headers() { let requests = transport.requests.lock().unwrap(); let headers = requests[0].headers(); let header = |name| { - headers - .iter() - .find(|header| header.name() == name) - .unwrap() - .value() - .expose() + std::str::from_utf8( + headers + .iter() + .find(|header| header.name() == name) + .unwrap() + .value(), + ) + .unwrap() }; assert_eq!(header("x-grok-client-version"), "9.8.7"); assert_eq!(header("x-grok-client-identifier"), "profile-contract"); diff --git a/backend/crates/providers/xai/tests/provider/contract/outbound.rs b/backend/crates/providers/xai/tests/provider/contract/outbound.rs new file mode 100644 index 000000000..b4cc39831 --- /dev/null +++ b/backend/crates/providers/xai/tests/provider/contract/outbound.rs @@ -0,0 +1,217 @@ +//! 验证 xAI 最终出站改写与真实传输、取消及账号事实的边界 + +use super::*; +use gateway_core::engine::middleware::outbound; +use provider_xai::ReqwestGrokInferenceTransport; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[derive(Debug)] +struct Rewrite { + uri: String, + calls: Arc, + invalid_header: bool, +} + +impl MiddlewarePlan for Rewrite { + fn has_outbound(&self, _context: &MiddlewareContext) -> bool { + true + } + + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: MiddlewareNext, + ) -> BoxFuture<'static, Result> { + next.run(request) + } + + fn handle_outbound( + &self, + context: MiddlewareContext, + mut request: outbound::Request, + next: outbound::Next, + ) -> BoxFuture<'static, Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(context.mount(), MiddlewareMount::Outbound); + assert!( + request + .headers + .iter() + .any(|header| header.name() == "user-agent") + ); + assert!(serde_json::from_slice::(&request.body).is_ok()); + request.target = outbound::Target::Http { + method: reqwest::Method::PATCH, + uri: self.uri.parse().unwrap(), + }; + request.headers = vec![ + MiddlewareHeader::new("x-multi", Bytes::from_static(b"one")), + MiddlewareHeader::new("X-Multi", Bytes::from_static(b"two")), + MiddlewareHeader::new("x-raw", Bytes::from_static(b"\x80")), + ]; + if self.invalid_header { + request + .headers + .push(MiddlewareHeader::new("invalid\nheader", Bytes::new())); + } + request.body = Bytes::from_static(b"\0\xffoutbound\x80"); + next.run(request) + } +} + +#[tokio::test] +async fn outbound_sends_final_method_headers_and_binary_bytes_for_generation_and_compaction() { + for compact in [false, true] { + let server = MockServer::start().await; + let response = if compact { + compaction_sse(&valid_compaction_summary("outbound compaction"), None) + } else { + SUCCESS_SSE.to_vec() + }; + Mock::given(method("PATCH")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200).set_body_raw(response, "text/event-stream")) + .expect(1) + .mount(&server) + .await; + let origin = url::Url::parse(&server.uri()).unwrap(); + let transport = Arc::new( + ReqwestGrokInferenceTransport::new(crate::support::loopback_endpoint_policy(&origin)) + .unwrap(), + ); + let provider = provider_with_catalog_transport( + StubSelector::success(), + transport, + StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable), + Arc::new(StaticCatalogTransport), + ) + .await; + let calls = Arc::new(AtomicUsize::new(0)); + let operation = if compact { + compaction_operation() + } else { + operation() + }; + let events = provider + .execute( + provider_request_with_operation("xai", operation), + context_with_middleware(Arc::new(Rewrite { + uri: origin.join("v1/responses").unwrap().to_string(), + calls: Arc::clone(&calls), + invalid_header: false, + })), + ) + .await + .unwrap() + .collect::>() + .await; + assert!(events.iter().all(Result::is_ok), "{events:?}"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + let sent = &requests[0]; + assert_eq!(sent.body, b"\0\xffoutbound\x80"); + assert!(!sent.headers.contains_key("user-agent")); + assert!(!sent.headers.contains_key("authorization")); + // 底层 reqwest 的缺省 Accept 不在可禁止范围内 + assert_eq!(sent.headers["accept"], "*/*"); + assert!(!sent.headers.contains_key("content-type")); + assert_eq!( + sent.headers + .get_all("x-multi") + .iter() + .map(|value| value.as_bytes()) + .collect::>(), + vec![b"one", b"two"] + ); + assert_eq!(sent.headers["x-raw"].as_bytes(), b"\x80"); + } +} + +#[tokio::test] +async fn invalid_outbound_header_fails_before_send_without_repair_or_retry() { + let transport = StubInferenceTransport::success(); + let provider = provider(StubSelector::success(), transport.clone()).await; + let calls = Arc::new(AtomicUsize::new(0)); + let mut stream = Arc::clone(&provider) + .execute( + provider_request("xai"), + context_with_middleware(Arc::new(Rewrite { + uri: "https://cli-chat-proxy.grok.com/v1/responses".to_owned(), + calls: Arc::clone(&calls), + invalid_header: true, + })), + ) + .await + .unwrap(); + let error = stream.next().await.unwrap().unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + drop(stream); + execute_successfully(&provider, operation()).await; + assert_eq!(transport.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn unpolled_stream_does_not_run_outbound_or_send() { + let transport = StubInferenceTransport::success(); + let provider = provider(StubSelector::success(), transport.clone()).await; + let calls = Arc::new(AtomicUsize::new(0)); + let stream = Arc::clone(&provider) + .execute( + provider_request("xai"), + context_with_middleware(Arc::new(Rewrite { + uri: "https://cli-chat-proxy.grok.com/v1/responses".to_owned(), + calls: Arc::clone(&calls), + invalid_header: false, + })), + ) + .await + .unwrap(); + drop(stream); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + execute_successfully(&provider, operation()).await; +} + +#[tokio::test] +async fn outbound_target_outside_transport_policy_fails_without_sending_or_retry() { + let server = MockServer::start().await; + let origin = url::Url::parse(&server.uri()).unwrap(); + let provider = provider_with_catalog_transport( + StubSelector::success(), + Arc::new( + ReqwestGrokInferenceTransport::new(crate::support::loopback_endpoint_policy(&origin)) + .unwrap(), + ), + StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable), + Arc::new(StaticCatalogTransport), + ) + .await; + let stream = provider + .execute( + provider_request("xai"), + context_with_middleware(Arc::new(Rewrite { + uri: origin.join("unsupported-target").unwrap().to_string(), + calls: Arc::new(AtomicUsize::new(0)), + invalid_header: false, + })), + ) + .await + .unwrap(); + let error = stream + .collect::>() + .await + .into_iter() + .find_map(Result::err) + .expect("transport policy rejection"); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); + assert!(server.received_requests().await.unwrap().is_empty()); +} diff --git a/docs/api.md b/docs/api.md index 3397d615c..7c09f8d03 100644 --- a/docs/api.md +++ b/docs/api.md @@ -308,14 +308,16 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 出站移除下游的 `traceparent`、`tracestate` 及 WS metadata 中对应的 `ws_request_header_traceparent`、`ws_request_header_tracestate` -会话、线程、窗口及父子线程引用在出站时按账号安装身份稳定映射为 UUID; -同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及 +会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID; +默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及 已识别的 Desktop 自动 Git 上下文使用一致映射。 轮次及父子轮次引用保留客户端原值,包括输入项的 `internal_chat_message_metadata_passthrough.turn_id`、 请求 metadata 中的 `turn_id`、`parent_turn_id`、`root_turn_id`,以及 `x-client-turn-id`、 Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUserMessageId`。 -输入项元数据保持不变。Search 的会话 `id` 按同一账号域映射,只替换该值的原始字节,其余正文保持不变。 -`prompt_cache_key` 同样按账号映射,保留同账号内的缓存共享关系;未提供时不补造。 +输入项元数据保持不变。Search 的会话 `id` 按同一安装身份域映射,只替换该值的原始字节,其余正文保持不变。 +`prompt_cache_key` 同样按安装身份映射,保留同一身份内的缓存共享关系;未提供时不补造。 +受信任插件可通过 attempt 构造参数选择本次安装身份;参数只影响出站构造,不改变实际选号与计费归属, +详见 [SDK 请求构造参数](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)。 续接 response ID、历史 item/call ID、业务正文和未知扩展不参与映射, 该处理减少显式追踪标识的跨账号复用,不保证会话内容或业务 metadata 无法关联 @@ -473,7 +475,7 @@ Images 排除已知为 Free 套餐的 OAuth 账号;套餐缺失或未知时不 `/v1/alpha/search` 使用相同的 OpenAI Provider 原生端点边界:模型不映射,正文除会话 `id` 的账号归一及显式请求策略外原样转发, Provider 旁路读取请求中的 `model`,按[账号模型限制](#账号模型限制)选号。 -`x-codex-turn-metadata` 在移除客户端账号身份并按当前 lease 重写 installation ID 后转发;上游账号 +`x-codex-turn-metadata` 在移除客户端账号身份并按本次构造参数重写 installation ID 后转发;上游账号 Authorization、Cookie、account ID、originator 和 User-Agent 均由代理安全重建。 Images 与 Search 保留通过现有过滤规则的普通业务请求头及多值字节;Responses 原连接的 turn state 不随独立端点转发,turn metadata 仍由所属端点按当前账号处理。 @@ -612,7 +614,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` 引导、sideband 与 hangup 的业务扩展头默认透传;认证、账号身份、下游环境与传输头遵守 [请求头与身份](#请求头与身份)边界。`X-Session-Id`、`Voice-Session-Id` 是 Live 业务会话标识,原样透传。 `Session-Id`、`Thread-Id` 等已识别追踪标识按选中账号的安装身份稳定映射,同账号各阶段保持一致, -换账号使用不同标识;客户端 trace 被剥离。 +换账号使用不同标识;客户端 trace 被剥离。创建请求可由 attempt 插件改写本次构造身份,后续 sideband / hangup 仍使用钉住账号的身份。 Live 不继承 Responses 的路由提示与续接状态;turn metadata 中的原账号绑定信息清理后保留其他字段。 真实 call ID 保留用于钉住账号访问通话。HTTP 业务头保留原始多值字节,sideband 握手沿用 WebSocket 可编码的头值 @@ -2527,9 +2529,13 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta `model_catalog`、`management`、`command_line` 和 `maintenance` 按清单声明注册,不接受 binding; `frontend_authentication` 必须由管理员显式配置身份映射,也不自动创建 -`middleware` v5 可绑定 `http`、`websocket`、`service`、`request` 和 `attempt`,按声明的挂载阶段分别配置。 +`middleware` v6 可绑定 `http`、`websocket`、`service`、`request`、`attempt` 和 `outbound`,按声明的挂载阶段分别配置。 `http`、`websocket` 和 `service` 不使用 Key、分组、Provider 或模型范围,这些数组必须为空;插件在处理器内匹配路径、消息或服务操作。 -`request` 与 `routing` 支持 Key、分组和公开模型范围,Provider 范围必须为空;`attempt`、`upstream`、`scheduling`、`retry` 与 `observation` 可使用 Provider 范围 +`request` 与 `routing` 支持 Key、分组和公开模型范围,Provider 范围必须为空;`attempt`、`outbound`、`upstream`、`scheduling`、`retry` 与 `observation` 可使用 Provider 范围 + +`outbound` 准备原生 Provider 交给 transport 的请求,覆盖 OpenAI Responses HTTP / WS、Images、Search、Live 创建及 xAI 生成与压缩。 +它使用最终上游模型匹配范围,下一层返回准备后的请求,不执行发送;字节、headers、HTTP 方法和目标的合同见 [SDK 出站请求准备](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#上游出站准备)。 +该阶段不包括 OAuth、额度刷新等后台请求,也不包括由 `upstream_adapter` 自行执行的发送 `observer` 固定使用 `observation/observe`,必填 `event` 为 `request_completed` 或 `websocket_response`。 每种事件最多一条绑定,可分别设置请求范围;同一实例两类事件的 `order` 必须相同。其他能力不接受非空 `event`。 diff --git a/docs/architecture.md b/docs/architecture.md index a23f37c2b..cbfb0cea1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,11 +167,20 @@ flowchart LR - **策略与中间件独立编译**:Runtime 的 `adapter/policy` 负责路由、调度与重试;`adapter/middleware` 负责各挂载边界的选择和组合,协议模块负责单层调用及正文生命周期 - **数据加工走中间件**:Core 的类型化 `compose` 统一维护顺序与单次续体;Runtime 发布时按挂载边界编译有序候选,调用时仅在当前候选中匹配作用域、捕获上下文并拼接续体,协议适配器负责单层 RPC 与资源投影。 - 接入按 HTTP、WebSocket 消息、逻辑请求、上游尝试和主动服务调用的生命周期边界组织。API 在路由匹配前组合所有公开 HTTP 接口; + 接入按 HTTP、WebSocket 消息、逻辑请求、上游尝试、出站准备和主动服务调用的生命周期边界组织。API 在路由匹配前组合所有公开 HTTP 接口; `http` 可改写路径、方法、完整 headers、惰性上传与响应流,也可直接返回新路由结果或接管自定义 WebSocket 升级。 握手、socket 与断连由 API 持有,会话处理通过原 RPC 资源流保活;HTTP 返回链替换响应时释放未提交的会话。 原生与插件接管的下游 WebSocket 共用消息组合和取消入口,在默认解析前和实际写入前处理完整消息;读写并发进行,插件调用期间不占用 transport 写锁。 `request` 包裹逻辑模型请求,`attempt` 包裹每次已选号的执行;请求顺序进入、响应逆序返回。 + attempt 可携带 Provider 的本次构造参数,Core / Runtime 只传递完整替换后的对象,解释和缺失校验由 Provider 负责; + 参数不改变真实租约、凭据版本和计费归属,也不承载宿主对插件策略的推断、补值或持久化。 + `outbound` 在原生默认值、隐私处理、序列化及内容压缩之后组合完整请求,续体只准备值,Provider 在整条返回链结束后发送。 + 无绑定或未命中当前请求范围时保留原生请求构造,不进入出站准备链。 + 它复用本次 attempt 的冻结计划、取消、期限和副作用水位;Provider 不再次应用业务默认值或重写正文,插件负责报文与内容编码的一致性。 + 原生 HTTP 客户端的默认行为仍生效,包括 reqwest 对缺省 Accept 的补齐,具体边界见 [SDK 出站准备](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#上游出站准备)。 + HTTP 方法、目标、重复头与原始正文由插件显式替换,仍须符合实际 transport 的格式与端点合同;xAI 的官方端点和 DNS 策略由其 transport 保留。 + WS 每轮准备候选 opening 与完整消息,新响应链按 opening 改写区分连接池项;精确续接使用实际拥有该链的连接,不重连或重放以兑现候选握手。 + Cookie 归属读取真实响应或 opening 地址,账号租约、凭据版本、已发送状态和续接事实仍由原 owner 持有。 中间件读取和改写完整 headers、正文及响应帧,不进行字段裁剪或额外读取授权;响应正文按需读取,转换和改写保留 Core 的交付、取消与结算事实。 Provider 来源与执行事件快照可由插件读取,包含费用明细、上游观测、会话状态和原始 wire;读取快照不转移计量和资源所有权。 数据面插件通过受管 HTTP 已发送、无法证明未发送,或进入主动 HTTP/公开服务调用链时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 @@ -457,7 +466,8 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 - OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义; 业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。 - `transport/correlation` 在出站副本上统一映射账号关联标识并剥离客户端 trace, + `transport/correlation` 在出站副本上按本次安装身份统一映射关联标识并剥离客户端 trace; + 安装身份默认取自账号,可由 attempt 的构造参数改写,缺失或非法结果不回退默认值。 内部选号、亲和、连接池与本地诊断继续读取原始身份;具体字段边界见 [API 协议](api.md#请求头与身份) canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。 WebSocket transport 按完整文本消息交付,wire 同时保留原文与可选解析视图;同协议出口使用原文, diff --git a/frontend/src/i18n/locales/en/plugins.json b/frontend/src/i18n/locales/en/plugins.json index 6b2e5c0a8..651d4c785 100644 --- a/frontend/src/i18n/locales/en/plugins.json +++ b/frontend/src/i18n/locales/en/plugins.json @@ -516,6 +516,7 @@ "allServiceCalls": "All service calls", "requestToStart": "Request to start", "everyTry": "Every try", + "outboundPreparation": "Outbound request preparation", "upstreamCall": "Upstream call", "accountScheduling": "Account scheduling", "retryDecision": "Retry decision", diff --git a/frontend/src/i18n/locales/zh-Hans/plugins.json b/frontend/src/i18n/locales/zh-Hans/plugins.json index c9926dfd3..ec2ef5fb7 100644 --- a/frontend/src/i18n/locales/zh-Hans/plugins.json +++ b/frontend/src/i18n/locales/zh-Hans/plugins.json @@ -516,6 +516,7 @@ "allServiceCalls": "所有服务调用", "requestToStart": "请求开始", "everyTry": "每次尝试", + "outboundPreparation": "出站请求准备", "upstreamCall": "上游调用", "accountScheduling": "账号调度", "retryDecision": "重试决策", diff --git a/frontend/src/i18n/locales/zh-Hant/plugins.json b/frontend/src/i18n/locales/zh-Hant/plugins.json index dd4d17885..a02e4ddad 100644 --- a/frontend/src/i18n/locales/zh-Hant/plugins.json +++ b/frontend/src/i18n/locales/zh-Hant/plugins.json @@ -516,6 +516,7 @@ "allServiceCalls": "所有服務調用", "requestToStart": "請求開始", "everyTry": "每次嘗試", + "outboundPreparation": "出站請求準備", "upstreamCall": "上游調用", "accountScheduling": "賬號調度", "retryDecision": "重試決策", diff --git a/frontend/src/views/plugins/constants.ts b/frontend/src/views/plugins/constants.ts index 66972f519..9db49296b 100644 --- a/frontend/src/views/plugins/constants.ts +++ b/frontend/src/views/plugins/constants.ts @@ -37,6 +37,7 @@ export const PLUGIN_REQUEST_STAGES = computed service: { label: t('plugins.fields.serviceCall'), scope: 'none', globalLabel: t('plugins.fields.allServiceCalls') }, request: { label: t('plugins.fields.requestToStart'), scope: 'model' }, attempt: { label: t('plugins.fields.everyTry'), scope: 'provider' }, + outbound: { label: t('plugins.fields.outboundPreparation'), scope: 'provider' }, upstream: { label: t('plugins.fields.upstreamCall'), scope: 'provider' }, routing: { label: t('plugins.fields.modelRouting'), scope: 'model' }, scheduling: { label: t('plugins.fields.accountScheduling'), scope: 'provider' }, diff --git a/modules/plugins b/modules/plugins index 74c928107..546f5c2c4 160000 --- a/modules/plugins +++ b/modules/plugins @@ -1 +1 @@ -Subproject commit 74c928107127820b7784bdf237444138ada11e8d +Subproject commit 546f5c2c48d0e2952baf0642cba3103729069bc6