mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(plugins): add provider request composition middleware (#511)
* feat(plugins): add provider request composition middleware Expose per-attempt provider parameters and a final outbound preparation chain using middleware v6. Preserve native transport behavior when no outbound scope matches. Co-authored-by: Codex <noreply@openai.com> * fix(plugins): sync workbench with middleware v6 Co-authored-by: Codex <noreply@openai.com> --------- Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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}
|
||||
},
|
||||
|
||||
|
||||
@@ -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<RequestSettings>,
|
||||
provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
protocol: String,
|
||||
headers: Vec<MiddlewareHeader>,
|
||||
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<String, serde_json::Value>> {
|
||||
self.provider_parameters.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_provider_parameters(
|
||||
mut self,
|
||||
parameters: serde_json::Map<String, serde_json::Value>,
|
||||
) -> Self {
|
||||
self.provider_parameters = Some(parameters);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn take_provider_parameters(
|
||||
&mut self,
|
||||
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||||
self.provider_parameters.take()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn protocol(&self) -> &str {
|
||||
&self.protocol
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod contract;
|
||||
pub mod http;
|
||||
pub mod outbound;
|
||||
mod plan;
|
||||
pub mod service;
|
||||
pub mod websocket;
|
||||
|
||||
@@ -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<MiddlewareHeader>,
|
||||
pub body: Bytes,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// 转换实际发送的 HTTP 头,保留多值与原始字节,不修复插件输出
|
||||
pub fn header_map(&self) -> Result<http::HeaderMap, ProviderError> {
|
||||
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<Request, Request, MiddlewareError>;
|
||||
|
||||
/// 保活本次 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<Request, ProviderError> {
|
||||
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")
|
||||
}
|
||||
@@ -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<super::outbound::Request, MiddlewareError>> {
|
||||
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<super::outbound::Request, MiddlewareError>> {
|
||||
context.plan = Some(self.clone());
|
||||
self.plan.handle_outbound(context, request, next)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn has_service(&self) -> bool {
|
||||
self.plan.has_service()
|
||||
|
||||
@@ -755,6 +755,7 @@ impl AttemptContext {
|
||||
provider: ProviderKind,
|
||||
model: Option<String>,
|
||||
account_id: ProviderAccountId,
|
||||
parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
terminal: provider::ProviderMiddlewareTerminal,
|
||||
) -> Result<provider::ProviderStream, ProviderError> {
|
||||
// 入口已校验原请求,此处复查中间件最终提交的 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<String>,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Option<middleware::outbound::Pipeline> {
|
||||
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<crate::operation::OperationKind>,
|
||||
provider: ProviderKind,
|
||||
model: Option<String>,
|
||||
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]
|
||||
|
||||
@@ -471,6 +471,7 @@ pub type ProviderMiddlewareTerminal = Box<
|
||||
dyn FnOnce(
|
||||
Operation,
|
||||
Vec<MiddlewareHeader>,
|
||||
Option<serde_json::Map<String, serde_json::Value>>,
|
||||
) -> BoxFuture<'static, Result<ProviderStream, ProviderError>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
@@ -489,18 +490,19 @@ fn provider_middleware_next(
|
||||
activate: oneshot::Receiver<()>,
|
||||
observation: Arc<Mutex<MiddlewareSendObservation>>,
|
||||
) -> 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<serde_json::Map<String, serde_json::Value>>,
|
||||
terminal: ProviderMiddlewareTerminal,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
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::<MiddlewareHeader>::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, .. } => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! 执行服务的准入、预算、请求记录与终结行为测试
|
||||
|
||||
mod outbound;
|
||||
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
|
||||
|
||||
use std::{
|
||||
|
||||
@@ -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<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<outbound::Request, MiddlewareError>> {
|
||||
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<MiddlewareResponse, MiddlewareError>> {
|
||||
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<Vec<ProviderModelCapabilities>, ProviderError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
self: Arc<Self>,
|
||||
request: ProviderRequest,
|
||||
context: AttemptContext,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
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::<ProviderEvent, _>(
|
||||
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<PreparingProvider>,
|
||||
observer: Arc<RecordingRequestObserver>,
|
||||
caught: Arc<AtomicUsize>,
|
||||
_extensions: Arc<ExecutionExtensionPlans>,
|
||||
}
|
||||
|
||||
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<dyn Provider>]).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);
|
||||
});
|
||||
}
|
||||
@@ -88,7 +88,7 @@ fn plan(action: Action) -> FrozenMiddlewarePlan {
|
||||
}
|
||||
|
||||
fn terminal(polls: Arc<AtomicUsize>, released: Arc<AtomicUsize>) -> 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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<Request, MiddlewareError>> {
|
||||
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<MiddlewareResponse, MiddlewareError>> {
|
||||
next.run(request)
|
||||
}
|
||||
}
|
||||
|
||||
fn pipeline(
|
||||
action: Action,
|
||||
limit: usize,
|
||||
cancellation: CancellationToken,
|
||||
deadline: SystemTime,
|
||||
) -> (Pipeline, Arc<AtomicUsize>) {
|
||||
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<AtomicUsize>) {
|
||||
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<_>>(),
|
||||
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);
|
||||
}
|
||||
@@ -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] },
|
||||
|
||||
@@ -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<gateway_core::engine::middleware::outbound::Request, MiddlewareError>,
|
||||
> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MiddlewareEntry>,
|
||||
context: MiddlewareContext,
|
||||
request: core::Request,
|
||||
next: core::Next,
|
||||
timeout: Duration,
|
||||
) -> Result<core::Request, MiddlewareError> {
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -135,6 +135,15 @@ pub(crate) enum MiddlewareCompletionBody {
|
||||
}
|
||||
|
||||
impl MiddlewareInvocation {
|
||||
pub(crate) fn provider_parameters(&self) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.original_request
|
||||
.provider_parameters()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn settings(&self) -> Result<serde_json::Value, PluginFault> {
|
||||
let state = self
|
||||
.state
|
||||
@@ -334,6 +343,12 @@ impl MiddlewareInvocation {
|
||||
request: MiddlewareNextRequest,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<MiddlewareRequest, PluginFault> {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Option<core::Next>>);
|
||||
|
||||
impl Invocation {
|
||||
pub(crate) fn new(next: core::Next) -> Arc<Self> {
|
||||
Arc::new(Self(Mutex::new(Some(next))))
|
||||
}
|
||||
}
|
||||
|
||||
impl MiddlewareCallback for Invocation {
|
||||
fn invoke(
|
||||
self: Arc<Self>,
|
||||
method: String,
|
||||
params: serde_json::Value,
|
||||
payload: Vec<u8>,
|
||||
_maximum_payload: usize,
|
||||
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
|
||||
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<u8>,
|
||||
) -> Result<core::Request, PluginFault> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -104,6 +104,7 @@ impl RpcError {
|
||||
Stage::WebSocket => "WebSocket 中间件:插件返回了无效响应",
|
||||
Stage::Request => "请求中间件:插件返回了无效响应",
|
||||
Stage::Attempt => "尝试中间件:插件返回了无效响应",
|
||||
Stage::Outbound => "出站准备中间件:插件返回了无效响应",
|
||||
Stage::Upstream => "上游适配阶段:插件返回了无效响应",
|
||||
Stage::Observation => "观察阶段:插件返回了无效响应",
|
||||
Stage::Management | Stage::PublicManagement => "管理阶段:插件返回了无效响应",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! 验证真实插件出站准备的组合、资源预算和终止边界
|
||||
|
||||
use super::*;
|
||||
use gateway_core::engine::middleware::outbound::{Next, Request, Target};
|
||||
|
||||
async fn runtime(
|
||||
instances: Vec<InstanceFixture>,
|
||||
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::<Vec<_>>(),
|
||||
[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<tokio::sync::oneshot::Sender<()>>);
|
||||
|
||||
impl Drop for NotifyOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(sender) = self.0.take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
"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<dyn std::error::Error>> {
|
||||
"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<dyn std::error::Error>> {
|
||||
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::<serde_json::Value>(&call.request.body).is_ok());
|
||||
@@ -590,7 +620,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(vec![frame])
|
||||
})?;
|
||||
}
|
||||
"passthrough" | "declare" | "settings" => {}
|
||||
"passthrough" | "declare" | "settings" | "parameters" => {}
|
||||
"inspect" => {
|
||||
response.body = response.body.inspect_frames(|frame| {
|
||||
assert!(!frame.payload.is_empty());
|
||||
|
||||
@@ -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<OutboundRequest, PluginFault> {
|
||||
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)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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` 中间件,响应观察接口保留只读合同
|
||||
|
||||
插件使用的导入路径例如:
|
||||
|
||||
|
||||
@@ -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<O>` | 已登记的公开业务服务 |
|
||||
| `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 保持最终裁决和预算 |
|
||||
|
||||
@@ -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` 中显式选择挂载;同一处理器可覆盖多个边界,协议格式等真实业务选择也不能省略。
|
||||
安装清单若携带不同的固定阶段会被拒绝,而不是在加载时静默改写
|
||||
|
||||
## 完整信任
|
||||
|
||||
@@ -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<serde_json::Map<String, serde_json::Value>>,
|
||||
pub request_id: String,
|
||||
pub client_key_id: String,
|
||||
pub account_group_ids: Vec<String>,
|
||||
@@ -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_json::Value>,
|
||||
/// 完整替换构造参数;空对象仍表示显式替换,不隐式合并或补回删除字段
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_replacement"
|
||||
)]
|
||||
pub provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -132,6 +147,15 @@ pub struct MiddlewareNextRequest {
|
||||
pub capabilities: Option<CapabilityDeclaration>,
|
||||
}
|
||||
|
||||
fn deserialize_replacement<'de, T, D>(deserializer: D) -> Result<Option<T>, 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 {
|
||||
|
||||
@@ -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<MiddlewareHeader>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub provider: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HttpCall>),
|
||||
WebSocket(Box<WebSocketCall>),
|
||||
Request(Box<RequestCall>),
|
||||
Outbound(Box<OutboundCall>),
|
||||
}
|
||||
pub enum MiddlewareResult {
|
||||
Service(ServiceResponse),
|
||||
Http(HttpResponse),
|
||||
WebSocket(Option<WebSocketMessage>),
|
||||
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<MiddlewareResult, PluginFault> {
|
||||
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<Self, PluginFault> {
|
||||
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<ServiceResponse> for MiddlewareResult {
|
||||
Self::Service(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OutboundRequest> for MiddlewareResult {
|
||||
fn from(request: OutboundRequest) -> Self {
|
||||
Self::Outbound(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8>,
|
||||
original_protocol: String,
|
||||
original_settings: serde_json::Value,
|
||||
original_provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
original_headers: Vec<MiddlewareHeader>,
|
||||
original_body: Vec<u8>,
|
||||
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,
|
||||
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
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<Self, PluginFault> {
|
||||
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<CallReply, PluginFault> {
|
||||
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<OutboundRequest, PluginFault> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -315,6 +315,7 @@ impl Manifest {
|
||||
| Stage::Service
|
||||
| Stage::Request
|
||||
| Stage::Attempt
|
||||
| Stage::Outbound
|
||||
)
|
||||
})))
|
||||
|| (*capability != Capability::Middleware
|
||||
|
||||
@@ -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::<MiddlewareNextRequest>(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![
|
||||
|
||||
@@ -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![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::<RequestHead>(value).is_err());
|
||||
assert!(serde_json::from_value::<CallHead>(json!({"request":{},"metadata":{}})).is_err());
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 验证类型化插件处理器的组合注册、分派与能力声明一致性
|
||||
|
||||
mod outbound;
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{
|
||||
|
||||
@@ -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::<outbound::RequestHead>(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::<outbound::RequestHead>(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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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<MiddlewareHeader>,
|
||||
mut request: RawJsonEndpointRequest,
|
||||
mut lease: CodexCredentialLease,
|
||||
account_selection_wait_ms: u64,
|
||||
selected: SelectedRequest,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
pub(super) response_origin: Option<Url>,
|
||||
pub(super) rate_limit_headers: Vec<(String, String)>,
|
||||
pub(super) observation: Option<ProviderResponseObservation>,
|
||||
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<ProviderDiagnostic> {
|
||||
"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!(
|
||||
|
||||
@@ -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<MiddlewareHeader>,
|
||||
mut lease: CodexCredentialLease,
|
||||
account_selection_wait_ms: u64,
|
||||
selected: SelectedRequest,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
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::<Vec<_>>();
|
||||
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<CodexLiveRegistry>,
|
||||
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(
|
||||
|
||||
@@ -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<ProviderStream, ProviderError> {
|
||||
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,
|
||||
|
||||
@@ -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<String, Value> {
|
||||
Map::from_iter([(
|
||||
"installation_id".into(),
|
||||
Value::String(installation_id.to_owned()),
|
||||
)])
|
||||
}
|
||||
|
||||
pub(super) fn decode(parameters: Option<Map<String, Value>>) -> Result<Self, ProviderError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,8 @@ impl fmt::Debug for CodexClientVisibleUpstreamResponse {
|
||||
/// Codex 上游 HTTP 客户端错误
|
||||
#[derive(Error)]
|
||||
pub enum CodexClientError {
|
||||
#[error("outbound request preparation failed")]
|
||||
Outbound(Box<gateway_core::error::ProviderError>),
|
||||
#[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<gateway_core::account::OutboundProxy>,
|
||||
pub(super) egress_key: String,
|
||||
pub(super) middleware_headers: Vec<MiddlewareHeader>,
|
||||
pub(super) outbound_middleware: Option<gateway_core::engine::middleware::outbound::Pipeline>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,6 +79,7 @@ pub struct CodexUpstreamFailure {
|
||||
pub(crate) usage_limit_resets_at: Option<i64>,
|
||||
pub(crate) retry_after_seconds: Option<u64>,
|
||||
pub(crate) request_id: Option<String>,
|
||||
pub(crate) response_origin: Option<url::Url>,
|
||||
pub(crate) set_cookie_headers: Vec<String>,
|
||||
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<u16>,
|
||||
pub request_id: Option<String>,
|
||||
pub identity_authorization_error: Option<String>,
|
||||
pub identity_error_code: Option<String>,
|
||||
pub trace_headers: Vec<(String, String)>,
|
||||
/// 实际响应所属 URL;WebSocket 沿用实际 opening,不能使用本次候选目标
|
||||
pub response_origin: Option<url::Url>,
|
||||
}
|
||||
|
||||
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(|_| "<redacted>"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexUpstreamDiagnostics {
|
||||
pub(crate) fn with_response_origin(mut self, url: Option<url::Url>) -> Self {
|
||||
self.response_origin = url;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn error_event_request_id(value: &Value) -> Option<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Pipeline>) -> Self {
|
||||
self.outbound_middleware = pipeline;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_http_request(
|
||||
&self,
|
||||
endpoint: String,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> CodexClientResult<reqwest::Request> {
|
||||
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<String>)> {
|
||||
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::<CodexClientResult<Vec<_>>>()?;
|
||||
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<String> {
|
||||
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()))
|
||||
}
|
||||
@@ -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<PayloadAuditSnapshot>,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(|| "<redacted>".to_owned());
|
||||
format!("GET {path} HTTP/1.1")
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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<Option<Vec<u8>>>,
|
||||
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<Option<Vec<u8>>>) -> CodexWebSocketExchangeError {
|
||||
fn websocket_opening_error(
|
||||
response: &WsResponse<Option<Vec<u8>>>,
|
||||
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<Option<Vec<u8>>>) -> 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> {
|
||||
reqwest::Url::parse(
|
||||
&connection
|
||||
.endpoint
|
||||
.replacen("wss://", "https://", 1)
|
||||
.replacen("ws://", "http://", 1),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn websocket_host_header(endpoint: &str) -> Option<String> {
|
||||
let url = reqwest::Url::parse(endpoint).ok()?;
|
||||
let host = url.host_str()?;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -7,12 +7,24 @@ use crate::transport::protocol::responses::{
|
||||
};
|
||||
|
||||
/// WebSocket opening 描述
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct CodexWebSocketConnection {
|
||||
pub(crate) connection_budget: Option<gateway_core::engine::connection::ConnectionBudget>,
|
||||
pub(crate) outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
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", &"<redacted>")
|
||||
.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,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,199 @@ fn cookie_provider(store: &Arc<MemoryAccountStore>, base_url: String) -> Arc<Cod
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_redirected_response_cookies_keep_the_actual_origin_on_success_and_failure() {
|
||||
for status in [200, 400] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let original = store.account("acct_provider_contract").unwrap();
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(status)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.insert_header("set-cookie", "cf_clearance=foreign; Path=/; Max-Age=3600")
|
||||
.set_body_string(if status == 200 {
|
||||
CAPTURE_COMPLETED_SSE
|
||||
} else {
|
||||
r#"{"error":{"message":"invalid","type":"invalid_request_error"}}"#
|
||||
}),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let target = format!(
|
||||
"{}/plugin-target",
|
||||
server.uri().replace("127.0.0.1", "localhost")
|
||||
);
|
||||
let mut stream = cookie_provider(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", http_generate_operation()),
|
||||
outbound_context("req_outbound_cookie_origin", move |request| {
|
||||
let gateway_core::engine::middleware::outbound::Target::Http { uri, .. } =
|
||||
&mut request.target
|
||||
else {
|
||||
panic!("HTTP target")
|
||||
};
|
||||
*uri = target.parse().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut failed = false;
|
||||
while let Some(event) = stream.next().await {
|
||||
if event.is_err() {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(failed, status != 200);
|
||||
let current = store.account("acct_provider_contract").unwrap();
|
||||
assert_eq!(
|
||||
current.revision(),
|
||||
original.revision(),
|
||||
"localhost response must not be attributed to the allowed 127.0.0.1 origin"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.repository()
|
||||
.load_complete_data(¤t)
|
||||
.await
|
||||
.unwrap()
|
||||
.cookies()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outbound_websocket_cookies_keep_the_actual_opening_origin_on_success_and_failure() {
|
||||
for rejected in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let original = store.account("acct_provider_contract").unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (release_server, finish) = oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
// 错误分支需要真实 HTTP 拒绝头;回调类型由 tungstenite 固定
|
||||
#[allow(clippy::result_large_err)]
|
||||
let result = tokio_tungstenite::accept_hdr_async_with_config(
|
||||
stream,
|
||||
|_request: &tungstenite::handshake::server::Request,
|
||||
mut response: tungstenite::handshake::server::Response| {
|
||||
if rejected {
|
||||
return Err(tungstenite::http::Response::builder()
|
||||
.status(400)
|
||||
.header("set-cookie", "cf_clearance=foreign; Path=/; Max-Age=3600")
|
||||
.body(Some(
|
||||
r#"{"error":{"message":"invalid","type":"invalid_request_error"}}"#
|
||||
.to_owned(),
|
||||
))
|
||||
.unwrap());
|
||||
}
|
||||
response.headers_mut().insert(
|
||||
"set-cookie",
|
||||
"cf_clearance=foreign; Path=/; Max-Age=3600"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
Ok(response)
|
||||
},
|
||||
Some(crate::transport::websocket_accept_config()),
|
||||
)
|
||||
.await;
|
||||
if rejected {
|
||||
assert!(result.is_err());
|
||||
return;
|
||||
}
|
||||
let mut socket = result.unwrap();
|
||||
assert!(matches!(
|
||||
socket.next().await.unwrap().unwrap(),
|
||||
Message::Text(_)
|
||||
));
|
||||
socket.send(Message::Text(json!({"type":"response.completed", "response":{"id":"resp_foreign_origin","model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap();
|
||||
let _ = finish.await;
|
||||
});
|
||||
let target = format!("ws://localhost:{}/plugin-target", address.port());
|
||||
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)),
|
||||
(
|
||||
"downstream_websocket_connection_id".into(),
|
||||
json!("outbound-cookie-origin"),
|
||||
),
|
||||
])),
|
||||
)
|
||||
.with_provider_session_state(
|
||||
ProviderSessionState::new(
|
||||
"openai",
|
||||
json!({
|
||||
"account_id":"acct_provider_contract",
|
||||
"conversation_id":"outbound-cookie-origin",
|
||||
"continuation_scope":"connection_local"
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
);
|
||||
let mut stream = cookie_provider(&store, format!("http://{address}"))
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
outbound_context("req_outbound_ws_cookie_origin", move |request| {
|
||||
let gateway_core::engine::middleware::outbound::Target::WebSocket { uri } =
|
||||
&mut request.target
|
||||
else {
|
||||
panic!("WebSocket target")
|
||||
};
|
||||
*uri = target.parse().unwrap();
|
||||
Ok(())
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut failure = None;
|
||||
timeout(Duration::from_secs(5), async {
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Err(error) = event {
|
||||
failure = Some(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(failure.is_some(), rejected, "{failure:?}");
|
||||
let current = store.account("acct_provider_contract").unwrap();
|
||||
assert_eq!(current.revision(), original.revision());
|
||||
assert!(
|
||||
store
|
||||
.repository()
|
||||
.load_complete_data(¤t)
|
||||
.await
|
||||
.unwrap()
|
||||
.cookies()
|
||||
.is_empty()
|
||||
);
|
||||
let _ = release_server.send(());
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_cookies_are_saved_once_for_stream_and_opening_failures() {
|
||||
for streaming in [true, false] {
|
||||
|
||||
@@ -2,6 +2,337 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParametersMiddleware(Map<String, Value>);
|
||||
|
||||
impl MiddlewarePlan for ParametersMiddleware {
|
||||
fn handle(
|
||||
&self,
|
||||
context: MiddlewareContext,
|
||||
request: MiddlewareRequest,
|
||||
next: MiddlewareNext,
|
||||
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
|
||||
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",
|
||||
|
||||
@@ -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<MiddlewareResponse, MiddlewareError>> {
|
||||
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<String, Value> = 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Rewrite>);
|
||||
|
||||
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<Request, MiddlewareError>> {
|
||||
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<MiddlewareResponse, MiddlewareError>> {
|
||||
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::<Value>(&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::<Vec<_>>(),
|
||||
["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<_>>(),
|
||||
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::<Vec<_>>(),
|
||||
["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::<Vec<_>>(),
|
||||
["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::<Value>(&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();
|
||||
}
|
||||
@@ -140,12 +140,14 @@ impl MiddlewarePlan for ChangeTurnMetadata {
|
||||
) -> BoxFuture<'static, Result<MiddlewareResponse, MiddlewareError>> {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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::<serde_json::Value>(&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<FrozenAccountScope> {
|
||||
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<MiddlewareResponse, MiddlewareError>> {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<GrokInferenceRequest, ProviderError> {
|
||||
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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GrokHeader>,
|
||||
headers: Vec<MiddlewareHeader>,
|
||||
body: Zeroizing<Vec<u8>>,
|
||||
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<MiddlewareHeader>,
|
||||
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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<_>>(),
|
||||
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::<Vec<_>>();
|
||||
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<dyn GrokSessionSelector>,
|
||||
transport: Arc<StubInferenceTransport>,
|
||||
transport: Arc<dyn GrokInferenceTransport>,
|
||||
recovery: Arc<StubRecovery>,
|
||||
catalog_transport: Arc<dyn GrokModelCatalogTransport>,
|
||||
) -> Arc<GrokBuildProvider> {
|
||||
@@ -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");
|
||||
|
||||
@@ -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<AtomicUsize>,
|
||||
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<MiddlewareResponse, MiddlewareError>> {
|
||||
next.run(request)
|
||||
}
|
||||
|
||||
fn handle_outbound(
|
||||
&self,
|
||||
context: MiddlewareContext,
|
||||
mut request: outbound::Request,
|
||||
next: outbound::Next,
|
||||
) -> BoxFuture<'static, Result<outbound::Request, MiddlewareError>> {
|
||||
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::<Value>(&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::<Vec<_>>()
|
||||
.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<_>>(),
|
||||
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::<Vec<_>>()
|
||||
.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());
|
||||
}
|
||||
+14
-8
@@ -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`。
|
||||
|
||||
+12
-2
@@ -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 同时保留原文与可选解析视图;同协议出口使用原文,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -516,6 +516,7 @@
|
||||
"allServiceCalls": "所有服务调用",
|
||||
"requestToStart": "请求开始",
|
||||
"everyTry": "每次尝试",
|
||||
"outboundPreparation": "出站请求准备",
|
||||
"upstreamCall": "上游调用",
|
||||
"accountScheduling": "账号调度",
|
||||
"retryDecision": "重试决策",
|
||||
|
||||
@@ -516,6 +516,7 @@
|
||||
"allServiceCalls": "所有服務調用",
|
||||
"requestToStart": "請求開始",
|
||||
"everyTry": "每次嘗試",
|
||||
"outboundPreparation": "出站請求準備",
|
||||
"upstreamCall": "上游調用",
|
||||
"accountScheduling": "賬號調度",
|
||||
"retryDecision": "重試決策",
|
||||
|
||||
@@ -37,6 +37,7 @@ export const PLUGIN_REQUEST_STAGES = computed<Record<string, PluginRequestStage>
|
||||
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' },
|
||||
|
||||
+1
-1
Submodule modules/plugins updated: 74c9281071...546f5c2c48
Reference in New Issue
Block a user