fix(plugins): preserve request origin and raw upstream observations (#497)

* fix(plugins): preserve request origin and raw upstream observations

Co-authored-by: Codex <noreply@openai.com>

* perf(plugins): share immutable response observations

Avoid copying response metadata and header collections for every stream event. Update Workbench byte headers and virtual-time timeout coverage, and align the large-request fixture with RPC v5.

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:
Zyy
2026-10-21 01:09:58 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 2480f07f2d
commit b8479b59db
40 changed files with 890 additions and 142 deletions
@@ -1582,6 +1582,12 @@ where
.attempt(self.attempts)
.record_provider_failure(&error);
let mut atomic_client_events = error.take_atomic_client_events();
// 失败随附的观测也属于当前 attempt,换号记录不能等到最终交付才读取
for event in &atomic_client_events {
if let Some(observation) = event.response_observation() {
self.observe_response(observation.clone());
}
}
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
if self.request_observation.is_some() {
let websocket_attempt = websocket_observation_attempt(&current);
@@ -65,6 +65,22 @@ pub struct ExecutionRequestMetadata {
pub previous_response_id: Option<PreviousResponseId>,
}
impl ExecutionRequestMetadata {
pub(super) fn inherit_request_origin(&mut self, scope: &ExtensionCallScope) {
let Some(request) = scope.request() else {
return;
};
// 执行入口可以是内部调用,客户端来源仍属于原始入口快照
self.client_ip = request.client_ip;
self.user_agent = request
.headers
.iter()
.find(|header| header.name().eq_ignore_ascii_case("user-agent"))
.and_then(|header| std::str::from_utf8(header.value()).ok())
.map(str::to_owned);
}
}
#[derive(Clone)]
pub struct AuthenticatedClient {
pub(super) settings: Option<RequestSettings>,
@@ -1001,8 +1001,7 @@ impl DefaultExecutionService {
let execution_effects_baseline = parent.graph.effects.epoch();
metadata.endpoint = "host.model".to_owned();
metadata.transport = ClientTransport::InternalPlugin;
metadata.client_ip = None;
metadata.user_agent = None;
metadata.inherit_request_origin(&extension_scope);
let authorization = AuthorizedExecution {
account_scope,
response_control: None,
@@ -1155,8 +1154,7 @@ impl DefaultExecutionService {
let execution_effects_baseline = graph.effects.epoch();
metadata.endpoint = "host.model".to_owned();
metadata.transport = ClientTransport::InternalPlugin;
metadata.client_ip = None;
metadata.user_agent = None;
metadata.inherit_request_origin(&authority.extension_scope);
self.client_api_key_usage
.record_used(authority.client.policy.key_id());
self.start_authorized(
+6 -5
View File
@@ -547,7 +547,7 @@ pub struct ProviderResponseTimings {
/// 这样 Provider 专有协议不会渗入路由
/// 或管理领域
#[derive(Clone, PartialEq, Eq)]
pub struct ProviderResponseMetadata(String);
pub struct ProviderResponseMetadata(Arc<str>);
impl ProviderResponseMetadata {
const MAX_BYTES: usize = 32 * 1024;
@@ -561,7 +561,7 @@ impl ProviderResponseMetadata {
serde_json::from_str::<Value>(&json)
.ok()
.filter(Value::is_object)
.map(|_| Self(json))
.map(|_| Self(json.into()))
}
/// 返回不透明的 JSON 文本,供持久化 adapter 原样验证和写入
@@ -626,7 +626,8 @@ pub struct ProviderResponseObservation {
service_tier: Option<String>,
upstream_response_model: Option<String>,
timings: ProviderResponseTimings,
client_headers: Vec<ProviderResponseHeader>,
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
client_headers: Arc<[ProviderResponseHeader]>,
provider_metadata: Option<ProviderResponseMetadata>,
}
@@ -642,7 +643,7 @@ impl ProviderResponseObservation {
service_tier: None,
upstream_response_model: None,
timings: ProviderResponseTimings::default(),
client_headers: Vec::new(),
client_headers: Arc::default(),
provider_metadata: None,
}
}
@@ -719,7 +720,7 @@ impl ProviderResponseObservation {
#[must_use]
pub fn with_client_headers(mut self, client_headers: Vec<ProviderResponseHeader>) -> Self {
self.client_headers = client_headers;
self.client_headers = client_headers.into();
self
}
@@ -3467,13 +3467,16 @@ fn explicit_429_after_structural_event_should_retry_before_commit() {
fn atomic_response_failed_should_rotate_account_before_stream_commit() {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut failure = atomic_response_failed("response-first-failed", "first account failure");
let mut events = failure.take_atomic_client_events();
events[0].attach_observation(
ProviderResponseObservation::new(UpstreamTransport::new("http_sse").unwrap())
.with_request_id(OpaqueUpstreamValue::new("upstream-failed-attempt")),
);
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_first",
items: vec![Err(atomic_response_failed(
"response-first-failed",
"first account failure",
))],
items: vec![Err(failure.with_atomic_client_events(events))],
},
Script::Stream {
account_id: "acct_second",
@@ -3516,6 +3519,10 @@ fn atomic_response_failed_should_rotate_account_before_stream_commit() {
let state = store.state.lock().expect("store lock");
assert_eq!(state.commits, 1);
assert_eq!(state.intermediate_failures, 1);
assert_eq!(
state.intermediate_request_ids,
[Some("upstream-failed-attempt".to_owned())]
);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
}
@@ -1195,6 +1195,114 @@ fn request_policy_snapshot(
.with_extensions(Some(generation))
}
fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope {
gateway_core::engine::extensions::ExtensionCallScope::default().with_request(Arc::new(
gateway_core::engine::extensions::RequestContext {
method: "POST".into(),
uri: "/plugin/proxy".into(),
http_version: "HTTP/1.1".into(),
headers: vec![gateway_core::engine::middleware::MiddlewareHeader::new(
"User-Agent",
Bytes::from_static(b"fixture-client/494"),
)],
peer_addr: None,
client_ip: Some("192.0.2.49".parse().unwrap()),
},
))
}
#[test]
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
use gateway_core::engine::nested::{
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
};
block_on(async {
for has_origin in [true, false] {
let store = Arc::new(TrackingExecutionStore::default());
let service = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(request_policy_snapshot(
super::extensions::reference("origin-fixture"),
&[ProviderKind::new("openai").unwrap()],
)),
store.clone(),
ProviderRegistry::new([Arc::new(ProcessingProvider) as Arc<dyn Provider>]).unwrap(),
Arc::new(Admissions::default()),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
Arc::new(crate::RecordingDiagnostics::default()),
);
let input = request(&service, ClientTransport::InternalPlugin);
let context = NestedModelExecutionPort::bind(
&service,
BoundModelExecutionBinding {
settings: None,
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
initiating_plugin_instance_id: "origin-fixture".into(),
cancellation: Default::default(),
extension_scope: if has_origin {
plugin_origin_scope()
} else {
Default::default()
},
},
)
.await
.unwrap();
let mut started = NestedModelExecutionPort::start_bound(
&service,
BoundModelExecutionRequest {
context: context.clone(),
public_model: input.public_model,
operation: input.operation,
metadata: input.metadata,
provider: None,
account: None,
},
)
.await
.unwrap();
let mut child = NestedModelExecutionPort::start(
&service,
NestedModelExecutionRequest {
parent_request_id: started.request_id.clone(),
initiating_plugin_instance_id: "nested-origin-fixture".into(),
public_model: PublicModelId::new("gpt-start").unwrap(),
operation: start_operation(),
metadata: request(&service, ClientTransport::InternalPlugin).metadata,
provider: None,
account: None,
parent_account: None,
},
)
.await
.unwrap();
child.session.collect_uncommitted().await.unwrap();
child.session.commit_downstream(Some(200)).await.unwrap();
child.session.detach_finalize().await;
started.session.collect_uncommitted().await.unwrap();
{
let requests = store.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
for request in requests.iter() {
assert_eq!(
request.client_ip,
has_origin.then(|| "192.0.2.49".parse().unwrap())
);
assert_eq!(
request.user_agent.as_deref(),
has_origin.then_some("fixture-client/494")
);
assert_eq!(
request.client_transport,
ClientTransport::InternalPlugin.as_str()
);
}
}
started.session.detach_finalize().await;
}
});
}
#[test]
fn completed_parent_rejects_new_nested_execution_and_cancels_an_active_child() {
block_on(async {
+61 -2
View File
@@ -3,8 +3,9 @@
use bytes::Bytes;
use gateway_core::event::{
ContentItem, ContentKind, EventSequenceError, EventSequenceValidator, GatewayEvent,
ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
ReasoningDelta, ResponseMeta, TextDelta, ToolCallDelta,
ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseMetadata,
ProviderResponseObservation, ProviderResponseTimings, ReasoningDelta, ResponseMeta, TextDelta,
ToolCallDelta,
};
use gateway_core::upstream::UpstreamTransport;
use serde_json::json;
@@ -335,3 +336,61 @@ fn validator_should_accept_reasoning_and_tool_call_stream() {
assert_eq!(validator.finish(), Ok(()));
}
#[test]
fn response_observation_snapshots_preserve_values_without_copying_large_payloads() {
let metadata = format!("{{\"raw\":\"{}\"}}", "a".repeat(32 * 1024 - 10));
let observation =
ProviderResponseObservation::new(UpstreamTransport::new("http_sse").expect("transport"))
.with_provider_metadata(
ProviderResponseMetadata::new(metadata.clone()).expect("metadata"),
)
.with_client_headers(
(0..32)
.map(|_| {
ProviderResponseHeader::new("set-cookie", Bytes::from_static(b"raw-\xff"))
})
.collect(),
)
.with_timings(ProviderResponseTimings {
first_event_ms: Some(17),
..ProviderResponseTimings::default()
});
let allocations = allocation_counter::measure(|| {
for _ in 0..1_000 {
std::hint::black_box(observation.clone());
}
});
// 允许小标量字段的复制,但流式快照不能随大头集合和 metadata 重复分配
assert!(allocations.bytes_total < 256 * 1_000, "{allocations:?}");
assert_eq!(allocations.bytes_current, 0);
let updated = observation
.clone()
.with_client_headers(Vec::new())
.with_provider_metadata(
ProviderResponseMetadata::new("{\"updated\":true}".to_owned()).expect("metadata"),
)
.with_timings(ProviderResponseTimings {
first_event_ms: Some(23),
..ProviderResponseTimings::default()
});
assert_eq!(
observation.provider_metadata().expect("metadata").as_json(),
metadata
);
assert_eq!(observation.client_headers().len(), 32);
assert!(
observation.client_headers().iter().all(|header| {
header.name() == "set-cookie" && header.value().as_ref() == b"raw-\xff"
})
);
assert_eq!(observation.timings().first_event_ms, Some(17));
assert_eq!(
updated.provider_metadata().expect("metadata").as_json(),
"{\"updated\":true}"
);
assert!(updated.client_headers().is_empty());
assert_eq!(updated.timings().first_event_ms, Some(23));
}
@@ -46,6 +46,7 @@ pub struct HttpRequest {
pub struct HttpResponse {
pub status: u16,
pub version: http::Version,
pub headers: Vec<(String, Vec<u8>)>,
pub body: HttpBody,
}
@@ -53,6 +54,7 @@ pub struct HttpResponse {
pub struct HttpBody {
response: hyper::body::Incoming,
pending: Bytes,
trailers: Vec<(String, Vec<u8>)>,
received: usize,
deadline: Instant,
_permit: OwnedSemaphorePermit,
@@ -66,7 +68,8 @@ pub struct HttpError {
pub send_state: UpstreamSendState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HttpErrorKind {
InvalidRequest,
AddressDenied,
@@ -366,10 +369,12 @@ impl PendingResponse {
pub(super) fn into_http(self) -> HttpResponse {
HttpResponse {
status: self.status,
version: self.response.version(),
headers: self.headers,
body: HttpBody {
response: self.response.into_body(),
pending: Bytes::new(),
trailers: Vec::new(),
received: 0,
deadline: self.deadline,
_permit: self.permit,
@@ -379,6 +384,11 @@ impl PendingResponse {
}
impl HttpBody {
/// 取出截至当前读取位置收到的尾部头,保留多值与原始字节
pub fn take_trailers(&mut self) -> Vec<(String, Vec<u8>)> {
std::mem::take(&mut self.trailers)
}
pub async fn read(&mut self, maximum: usize) -> Result<Option<Bytes>, HttpError> {
self.read_until(maximum, self.deadline).await
}
@@ -414,7 +424,15 @@ impl HttpBody {
self.pending = match tokio::time::timeout_at(deadline, self.response.frame()).await {
Ok(Some(Ok(frame))) => match frame.into_data() {
Ok(chunk) => chunk,
Err(_) => continue,
Err(frame) => {
let trailers = frame
.into_trailers()
.map_err(|_| HttpError::sent("response frame"))?;
self.trailers.extend(trailers.iter().map(|(name, value)| {
(name.as_str().to_owned(), value.as_bytes().to_vec())
}));
continue;
}
},
Ok(None) => return Ok(None),
Ok(Some(Err(_))) => return Err(HttpError::sent("response body")),
@@ -27,6 +27,8 @@ type Socket = WebSocketStream<TokioIo<hyper::upgrade::Upgraded>>;
/// 读写分别串行,允许等待输出时发送控制消息;关闭会唤醒在途操作并释放容量
pub struct ManagedWebSocket {
handshake_version: http::Version,
handshake_headers: Vec<(String, Vec<u8>)>,
writer: Mutex<Option<(SplitSink<Socket, Message>, OwnedSemaphorePermit)>>,
reader: Mutex<Option<SplitStream<Socket>>>,
closed: watch::Sender<bool>,
@@ -39,6 +41,7 @@ pub enum WebSocketMessage {
pub struct WebSocketResponse {
pub status: u16,
pub version: http::Version,
pub headers: Vec<(String, Vec<u8>)>,
pub connection: Option<ManagedWebSocket>,
pub body: Option<HttpBody>,
@@ -70,6 +73,7 @@ impl HttpClient {
let response = response.into_http();
return Ok(WebSocketResponse {
status: response.status,
version: response.version,
headers: response.headers,
connection: None,
body: Some(response.body),
@@ -112,8 +116,11 @@ impl HttpClient {
let (writer, reader) = socket.split();
Ok(WebSocketResponse {
status: response.status,
headers: response.headers,
version: response.response.version(),
headers: response.headers.clone(),
connection: Some(ManagedWebSocket {
handshake_version: response.response.version(),
handshake_headers: response.headers,
writer: Mutex::new(Some((writer, response.permit))),
reader: Mutex::new(Some(reader)),
closed: watch::channel(false).0,
@@ -124,6 +131,17 @@ impl HttpClient {
}
impl ManagedWebSocket {
/// 连接复用仍可读取最初握手的原始元数据,不伪造一次新握手
#[must_use]
pub const fn handshake_version(&self) -> http::Version {
self.handshake_version
}
#[must_use]
pub fn handshake_headers(&self) -> &[(String, Vec<u8>)] {
&self.handshake_headers
}
pub async fn send(
&self,
message: WebSocketMessage,
@@ -1,7 +1,7 @@
{
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [4],
"protocol_versions": [5],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
@@ -9,7 +9,7 @@
{ "capability": "model_catalog", "versions": [1] },
{ "capability": "retry_policy", "versions": [2] },
{ "capability": "middleware", "versions": [5] },
{ "capability": "upstream_adapter", "versions": [3] },
{ "capability": "upstream_adapter", "versions": [4] },
{ "capability": "observer", "versions": [2] },
{ "capability": "command_line", "versions": [1] },
{ "capability": "management", "versions": [2] },
@@ -24,7 +24,7 @@ use gateway_protocol::openai::sse::SseEventDecoder;
pub(super) struct EventDecoder {
sequence: EventSequenceValidator,
response_id: Option<String>,
service_tier: Option<String>,
observation: Option<ProviderResponseObservation>,
usage: Usage,
contents: usize,
pub(super) completed: bool,
@@ -54,6 +54,7 @@ impl EventDecoder {
transport: &str,
account: &dyn UpstreamAccountConnection,
sent: UpstreamSendState,
started_at: std::time::Instant,
) -> Result<DecodedEvent, EventDecodeError> {
let message = UpstreamAdapterEvent::decode(payload)
.map_err(|source| invalid(sent).with_source(source))?;
@@ -65,22 +66,31 @@ impl EventDecoder {
.wire
.map(|wire| decode_wire(wire, protocol, sent))
.transpose()?;
let has_observation = self.observation.is_some() || message.observation.is_some();
let mut observation = match self.observation.take() {
Some(observation) => observation,
None => ProviderResponseObservation::new(
UpstreamTransport::new(transport)
.map_err(|source| invalid(sent).with_source(source))?,
),
};
if let Some(update) = message.observation {
observation = super::observation::merge(observation, update, sent)?;
}
if let Some(failure) = message.failure {
if !message.event.facts.is_empty() || message.continuation.is_some() {
return Err(invalid(sent).into());
}
let mut error = failure_error(failure, sent)?;
let mut events = Vec::new();
if has_observation {
events.push(ProviderEvent::observation(observation));
}
if let Some(wire) = wire {
error = error.with_atomic_client_events(vec![ProviderEvent::wire(wire)]);
events.push(ProviderEvent::wire(wire));
}
return Err(EventDecodeError::Upstream(error));
}
if let Some(tier) = message.service_tier {
if tier.is_empty() || tier.len() > 64 || tier.chars().any(char::is_control) {
return Err(invalid(sent).into());
}
// 上游可能在完成时才把 auto 解析为实际档位;与原生观测一样保留最新值
self.service_tier = Some(tier);
return Err(EventDecodeError::Upstream(
failure_error(failure, sent)?.with_atomic_client_events(events),
));
}
let mut facts = Vec::new();
for fact in message.event.facts {
@@ -181,7 +191,7 @@ impl EventDecoder {
return Err(invalid(sent).into());
}
if let Some(cost) =
account.calculate_cost(self.service_tier.as_deref(), &self.usage)
account.calculate_cost(observation.service_tier(), &self.usage)
{
let cost = GatewayEvent::CalculatedCost(cost);
self.sequence
@@ -213,22 +223,26 @@ impl EventDecoder {
if message.continuation.is_some() && !self.completed {
return Err(invalid(sent).into());
}
observation =
super::observation::observe_output(observation, &facts, wire.is_some(), started_at);
self.observation = Some(observation.clone());
let mut event = if let Some(wire) = wire {
ProviderEvent::canonical_with_wire(facts, wire)
} else {
let mut facts = facts.into_iter();
let first = facts.next().ok_or_else(|| invalid(sent))?;
facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact)
match facts.next() {
Some(first) => {
facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact)
}
None => {
return Ok(DecodedEvent {
event: ProviderEvent::observation(observation),
continuation: message.continuation,
});
}
}
};
if let Some(tier) = &self.service_tier {
let observation = ProviderResponseObservation::new(
UpstreamTransport::new(transport)
.map_err(|source| invalid(sent).with_source(source))?,
)
.try_with_service_tier(tier.clone())
.map_err(|source| invalid(sent).with_source(source))?;
event.attach_observation(observation);
}
event.attach_observation(observation);
Ok(DecodedEvent {
event,
continuation: message.continuation,
@@ -308,6 +322,20 @@ fn decode_wire(
ProtocolWireEvent::json_with_sse_metadata(protocol, event, data, id, retry)
}
}
WirePayload::WebSocket {
event,
data,
message,
} => {
let message =
String::from_utf8(message).map_err(|source| invalid(sent).with_source(source))?;
if let Some(data) = data {
ProtocolWireEvent::json(protocol, event, data)
.map(|wire| wire.with_raw_websocket_message(message))
} else {
ProtocolWireEvent::raw_websocket(protocol, message)
}
}
WirePayload::RawSse { frame } => {
single_sse(&frame, sent)?;
ProtocolWireEvent::raw_sse(protocol, Bytes::from(frame))
@@ -106,6 +106,7 @@ impl Execution {
self.adapter.declaration.transport.as_str(),
self.invocation.account.as_ref(),
active.managed.send_state.get(),
self.invocation.context.timing_started_at(),
)
.map_err(|error| match error {
EventDecodeError::Invalid(error) => {
@@ -131,7 +132,7 @@ impl Execution {
}.await;
if let Err(error) = terminal {
// 尾部故障不得交付成功 wire,但已经解析的计量事实仍归 Core 结算
let metering = decoded
let mut metering: Vec<_> = decoded
.event
.canonical_facts()
.iter()
@@ -145,6 +146,11 @@ impl Execution {
.cloned()
.map(gateway_core::event::ProviderEvent::canonical)
.collect();
if let Some(observation) = decoded.event.response_observation() {
metering.push(gateway_core::event::ProviderEvent::observation(
observation.clone(),
));
}
return Err(error.with_atomic_client_events(metering));
}
self.ended = true;
@@ -243,6 +249,10 @@ impl Execution {
protocol: invocation.operation.protocol().to_owned(),
client_transport: invocation.context.client_transport().as_str().to_owned(),
fast_mode: invocation.context.fast_mode().as_str().to_owned(),
request_elapsed_ms: u64::try_from(
invocation.context.timing_started_at().elapsed().as_millis(),
)
.unwrap_or(u64::MAX),
headers: invocation
.headers
.iter()
@@ -3,6 +3,7 @@
mod event;
mod execution;
mod failure;
mod observation;
mod registration;
pub(crate) mod target;
@@ -0,0 +1,129 @@
//! 合并适配器的原始上游观测,并记录宿主实际收到规范化输出的时间
use gateway_core::event::{
GatewayEvent, ProviderResponseHeader, ProviderResponseMetadata, ProviderResponseObservation,
ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
};
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
use gateway_plugin_sdk::call::model::facts::ResponseObservation;
use super::failure::invalid;
pub(super) fn merge(
mut current: ProviderResponseObservation,
update: ResponseObservation,
sent: UpstreamSendState,
) -> Result<ProviderResponseObservation, gateway_core::error::ProviderError> {
// 传输类型由已选适配器固定,不能让不一致的报告绕过 Core 的观测归属
if update.transport != current.transport().as_str() {
return Err(invalid(sent));
}
if let Some(value) = update.http_version {
let version = if value == "unknown" {
UpstreamHttpVersion::Unknown
} else {
UpstreamHttpVersion::parse(&value).ok_or_else(|| invalid(sent))?
};
current = current.with_http_version(version);
}
if let Some(value) = update.websocket_pool {
current = current.with_websocket_pool(match value.as_str() {
"new" => WebSocketPoolKind::New,
"reuse" => WebSocketPoolKind::Reuse,
_ => return Err(invalid(sent)),
});
}
if let Some(value) = update.status_code {
if !(100..=599).contains(&value) {
return Err(invalid(sent));
}
current = current.with_status_code(value);
}
if let Some(value) = update.request_id {
current = current.with_request_id(OpaqueUpstreamValue::new(value));
}
if let Some(value) = update.service_tier {
current = current
.try_with_service_tier(value)
.map_err(|source| invalid(sent).with_source(source))?;
}
if let Some(value) = update.upstream_response_model {
current = current.with_upstream_response_model_if_valid(&value);
if current.upstream_response_model() != Some(value.trim()) {
return Err(invalid(sent));
}
}
if !update.client_headers.is_empty() {
current = current.with_client_headers(
update
.client_headers
.into_iter()
.map(|header| ProviderResponseHeader::new(header.name, header.value.into()))
.collect(),
);
}
if let Some(value) = update.provider_metadata {
current = current.with_provider_metadata(
ProviderResponseMetadata::new(value).ok_or_else(|| invalid(sent))?,
);
}
let previous = current.timings();
let update = update.timings;
let timings = ProviderResponseTimings {
transport_decision_wait_ms: update
.transport_decision_wait_ms
.or(previous.transport_decision_wait_ms),
connect_ms: update.connect_ms.or(previous.connect_ms),
headers_ms: update.headers_ms.or(previous.headers_ms),
first_event_ms: update.first_event_ms.or(previous.first_event_ms),
first_reasoning_ms: update.first_reasoning_ms.or(previous.first_reasoning_ms),
first_text_ms: update.first_text_ms.or(previous.first_text_ms),
first_token_ms: update.first_token_ms.or(previous.first_token_ms),
provider_processing_ms: update
.provider_processing_ms
.or(previous.provider_processing_ms),
upstream_response_ms: update
.upstream_response_ms
.or(previous.upstream_response_ms),
upstream_api_overhead_ms: update
.upstream_api_overhead_ms
.or(previous.upstream_api_overhead_ms),
upstream_engine_ms: update.upstream_engine_ms.or(previous.upstream_engine_ms),
};
Ok(current.with_timings(timings))
}
pub(super) fn observe_output(
current: ProviderResponseObservation,
facts: &[GatewayEvent],
has_wire: bool,
started_at: std::time::Instant,
) -> ProviderResponseObservation {
let elapsed = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let mut timings = current.timings();
if has_wire || !facts.is_empty() {
timings.first_event_ms.get_or_insert(elapsed);
}
// 内容边界来自插件报告的 canonical 事实,不按上游私有事件名猜测
for fact in facts {
let has_output = match fact {
GatewayEvent::TextDelta(delta) if !delta.text.is_empty() => {
timings.first_text_ms.get_or_insert(elapsed);
true
}
GatewayEvent::ReasoningDelta(delta) if !delta.text.is_empty() => {
timings.first_reasoning_ms.get_or_insert(elapsed);
true
}
GatewayEvent::ToolCallDelta(delta) => {
!delta.arguments_delta.is_empty()
|| delta.name.as_ref().is_some_and(|name| !name.is_empty())
}
_ => false,
};
if has_output {
timings.first_token_ms.get_or_insert(elapsed);
}
}
current.with_timings(timings)
}
@@ -18,7 +18,7 @@ use gateway_plugin_sdk::{
FinishReason as WireFinishReason, Usage as WireUsage, WireEvent, WirePayload,
facts::{self, ExecutionCost, ExecutionFacts},
},
observation::{RequestMoney, RequestTimings},
observation::RequestMoney,
},
};
@@ -120,7 +120,7 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
request_id: value.request_id().map(|value| value.as_str().to_owned()),
service_tier: value.service_tier().map(str::to_owned),
upstream_response_model: value.upstream_response_model().map(str::to_owned),
timings: RequestTimings {
timings: facts::ResponseTimings {
transport_decision_wait_ms: timings.transport_decision_wait_ms,
connect_ms: timings.connect_ms,
headers_ms: timings.headers_ms,
@@ -132,7 +132,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
upstream_response_ms: timings.upstream_response_ms,
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
upstream_engine_ms: timings.upstream_engine_ms,
latency_ms: None,
},
client_headers: value
.client_headers()
@@ -271,6 +270,12 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
WirePayload::RawJson {
body: body.to_vec(),
}
} else if let Some(message) = wire.raw_websocket_message() {
WirePayload::WebSocket {
event: wire.event_type().map(str::to_owned),
data: wire.has_json_data().then(|| wire.data().clone()),
message: message.as_bytes().to_vec(),
}
} else if wire.has_json_data() {
WirePayload::Json {
event: wire.event_type().map(str::to_owned),
@@ -283,15 +288,6 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
WirePayload::RawSse {
frame: frame.to_vec(),
}
} else if let Some(message) = wire.raw_websocket_message() {
// 子模型回调采用 SDK 的事件流表达,不把 WebSocket 文本冒充 HTTP body
WirePayload::RawSse {
frame: gateway_protocol::openai::sse::encode_sse_event(
wire.event_type().unwrap_or_default(),
message,
)
.into_bytes(),
}
} else {
return Err(PluginFault::new(
ErrorCode::Fault,
@@ -58,16 +58,12 @@ impl HttpCallbacks<'_> {
);
let response = response.map_err(http_error)?;
let mut body = response.body;
let headers = response
.headers
.into_iter()
.map(|(name, value)| String::from_utf8(value).map(|value| (name, value)))
.collect::<Result<Vec<_>, _>>()
.map_err(|_| sent_fault(ErrorCode::Fault, "HTTP header cannot be represented"))?;
let mut result = wire::HttpResponse {
status: response.status,
headers,
http_version: format!("{:?}", response.version),
headers: response.headers,
stream: None,
trailers: Vec::new(),
};
let mut payload = Vec::new();
if streaming {
@@ -103,6 +99,7 @@ impl HttpCallbacks<'_> {
}
payload.extend_from_slice(&chunk);
}
result.trailers = body.take_trailers();
}
Ok(RpcReply {
result: serde_json::to_value(result).map_err(|_| invalid())?,
@@ -124,11 +121,7 @@ impl HttpCallbacks<'_> {
HttpRequest {
method: request.method,
url: request.url,
headers: request
.headers
.into_iter()
.map(|(name, value)| (name, value.into_bytes()))
.collect(),
headers: request.headers,
maximum_body_bytes: self.body_limit(&payload)?,
body: payload,
},
@@ -163,6 +156,9 @@ impl HttpCallbacks<'_> {
_ = closed.wait_for(|closed| *closed) => Err(denied()),
result = body.as_mut().ok_or_else(denied)?.read_with_timeout(request.maximum_bytes as usize, timeout) => result.map_err(http_error),
};
let trailers = body
.as_mut()
.map_or_else(Vec::new, |body| body.take_trailers());
if !matches!(&result, Ok(Some(_))) {
body.take();
stream.closed.send_replace(true);
@@ -174,7 +170,7 @@ impl HttpCallbacks<'_> {
}
let chunk = result?;
Ok(RpcReply {
result: serde_json::json!({"eof":chunk.is_none()}),
result: serde_json::json!({"eof":chunk.is_none(), "trailers": trailers}),
payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()),
})
}
@@ -231,6 +227,10 @@ pub(super) fn http_error(error: HttpError) -> PluginFault {
HttpErrorKind::Response => (ErrorCode::Upstream, "目标网站响应读取失败"),
};
let mut fault = PluginFault::new(code, message);
fault.details = Some(serde_json::json!({
"kind": error.kind(),
"reason": error.reason(),
}));
fault.send_state = match error.send_state {
UpstreamSendState::NotSent => SendState::NotSent,
UpstreamSendState::Sent => SendState::Sent,
@@ -159,12 +159,7 @@ impl ManagedUpstream {
(name, value.to_vec())
})
.collect();
headers.extend(
request
.headers
.into_iter()
.map(|(name, value)| (name, value.into_bytes())),
);
headers.extend(request.headers);
Ok((
HttpRequest {
method: request.method,
@@ -237,7 +232,7 @@ pub(super) async fn dispatch(
return Err(denied());
}
let mut socket = managed.socket.try_lock().map_err(|_| denied())?;
if let Some((url, _)) = socket
if let Some((url, connection)) = socket
.as_ref()
.filter(|(_, connection)| !connection.is_closed())
{
@@ -245,7 +240,13 @@ pub(super) async fn dispatch(
return Err(denied());
}
return Ok(RpcReply {
result: serde_json::json!({"status":101,"headers":[],"stream":null}),
result: serde_json::json!({
"status": 101,
"http_version": format!("{:?}", connection.handshake_version()),
"headers": connection.handshake_headers(),
"stream": null,
"trailers": [],
}),
payload: vec![],
});
}
@@ -266,12 +267,8 @@ pub(super) async fn dispatch(
.map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent),
);
let mut response = response.map_err(super::http::http_error)?;
let headers = response
.headers
.into_iter()
.map(|(name, value)| String::from_utf8(value).map(|value| (name, value)))
.collect::<Result<Vec<_>, _>>()
.map_err(|_| invalid())?;
let headers = response.headers;
let mut trailers = Vec::new();
let mut payload = Vec::new();
if let Some(body) = response.body.as_mut() {
while let Some(chunk) = body
@@ -284,6 +281,7 @@ pub(super) async fn dispatch(
}
payload.extend_from_slice(&chunk);
}
trailers = body.take_trailers();
}
if let Some(connection) = response.connection {
*socket = Some((url, Arc::new(connection)));
@@ -291,8 +289,10 @@ pub(super) async fn dispatch(
Ok(RpcReply {
result: serde_json::to_value(wire::HttpResponse {
status: response.status,
http_version: format!("{:?}", response.version),
headers,
stream: None,
trailers,
})
.map_err(|_| invalid())?,
payload,
@@ -102,7 +102,21 @@ async fn reading_websocket_facts_preserves_original_text_and_opaque_messages() {
);
}
assert_eq!(delivered, messages);
assert_eq!(marker_lines(&marker, 4).await.len(), 4);
let snapshots = marker_lines(&marker, 4).await;
for (snapshot, original) in snapshots[1..].iter().zip(&messages) {
let event: gateway_plugin_sdk::call::model::ExecutionEvent =
serde_json::from_value(snapshot.clone()).unwrap();
let gateway_plugin_sdk::call::model::WirePayload::WebSocket { data, message, .. } =
event.wire.unwrap().payload
else {
panic!("WebSocket facts must retain their original transport");
};
assert_eq!(message, original.as_bytes());
assert_eq!(
data.is_some(),
serde_json::from_str::<serde_json::Value>(original).is_ok()
);
}
drop(stream);
runtime.shutdown().await;
}
@@ -87,7 +87,7 @@ impl UpstreamAccountConnection for Account {
fn configuration(base: &str, streaming: bool) -> Value {
json!({
"upstream_registration":{"adapters":[{"id":"example","provider":"openai","base_url":format!("{base}/adapter/"),"paths":[{"path":"responses","purpose":"inference"}],"authentication_kinds":["oauth"],"transport":if streaming {"http_sse"} else {"http_json"},"protocol":"openai"}]},
"upstream_callbacks":[{"method":if streaming {"host.upstream.http.do_stream"} else {"host.upstream.http.do"},"params":{"method":"POST","path":"responses","headers":[["content-type","application/json"]]},"body":"request"}],
"upstream_callbacks":[{"method":if streaming {"host.upstream.http.do_stream"} else {"host.upstream.http.do"},"params":{"method":"POST","path":"responses","headers":[["content-type",b"application/json".to_vec()]]},"body":"request"}],
"upstream_events":[
{"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"}]}},
{"event":{"facts":[{"type":"usage","usage":{"input_tokens":7,"output_tokens":2}},{"type":"completed","id":"response-one","model":"native-model","reason":"stop"}]},"wire":if streaming {"sse"} else {"http"},"continuation":{"scope":"persisted","upstream_response_id":"private-upstream-id","state":{"cursor":"opaque"}}}
@@ -457,6 +457,193 @@ async fn adapter_sse_outlives_rpc_budget_and_cancellation_closes_its_resources()
}
}
#[tokio::test]
async fn adapter_preserves_parsed_and_opaque_websocket_messages() {
use gateway_plugin_sdk::call::model::{WireEvent, WirePayload};
let messages = [
"{ \"type\": \"future.event\", \"value\": 1e3 }".to_owned(),
"opaque future message".to_owned(),
format!("{{\"value\":{}0{}}}", "[".repeat(140), "]".repeat(140)),
];
let mut config = configuration("http://127.0.0.1:1", false);
config["upstream_callbacks"] = json!([]);
config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket");
let mut events = vec![config["upstream_events"][0].clone()];
for message in &messages {
events.push(json!({"event":{"facts":[],"wire":WireEvent {
protocol: "openai".into(),
payload: WirePayload::WebSocket {
event: None, data: serde_json::from_str(message).ok(), message: message.as_bytes().to_vec(),
},
}}}));
}
events.push(json!({"event":config["upstream_events"][1]["event"].clone()}));
config["upstream_events"] = json!(events);
let (cache, _, runtime) = setup(config).await;
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None);
let mut received = Vec::new();
while let Some(event) = stream.next().await {
if let Some(wire) = event.unwrap().wire_event() {
assert!(wire.raw_sse_frame().is_none());
received.push(wire.raw_websocket_message().unwrap().to_owned());
}
}
assert_eq!(received, messages);
drop(stream);
drop(generation);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn managed_http_failure_keeps_host_reason_and_error_kind() {
let mut config = configuration("http://127.0.0.1:1", false);
config["upstream_callbacks"][0]["params"]["method"] = json!("invalid method");
let (cache, _, runtime) = setup(config).await;
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None);
let error = stream.next().await.unwrap().unwrap_err();
let raw: Value = serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap();
assert_eq!(raw["fault"]["details"]["kind"], "invalid_request");
assert_eq!(raw["fault"]["details"]["reason"], "method");
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
drop(stream);
drop(generation);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn adapter_preserves_observation_updates_and_measures_only_real_output() {
let mut config = configuration("http://127.0.0.1:1", true);
config["upstream_callbacks"] = json!([]);
config["upstream_events"] = json!([
{"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"},
{"type":"content_added","index":0,"kind":"reasoning"}]},
"observation":{"transport":"http_sse","http_version":"HTTP/2","status_code":200,
"request_id":"upstream-494","upstream_response_model":"real-model",
"client_headers":[{"name":"x-bytes","value":[255]},{"name":"x-bytes","value":[254]}],
"provider_metadata":"{ \"future\": 1e3 }","timings":{"connect_ms":5,"headers_ms":8}}},
{"event":{"facts":[{"type":"reasoning_delta","index":0,"text":""}]}},
{"event":{"facts":[{"type":"reasoning_delta","index":0,"text":"thinking"}]}},
{"event":{"facts":[{"type":"content_added","index":1,"kind":"text"},{"type":"text_delta","index":1,"text":"answer"}]}},
{"event":{"facts":[]},"observation":{"transport":"http_sse","service_tier":"priority",
"timings":{"upstream_response_ms":1000,"upstream_api_overhead_ms":1.25,"upstream_engine_ms":998.75}}},
{"event":{"facts":[{"type":"usage","usage":{"input_tokens":7,"output_tokens":2}},
{"type":"completed","id":"response-one","model":"native-model","reason":"stop"}]},
"observation":{"transport":"http_sse","timings":{"first_token_ms":41}}}
]);
let (cache, _, runtime) = setup(config).await;
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
let context = context(&runtime, &generation, "key-one");
tokio::time::sleep(Duration::from_millis(50)).await;
let mut stream = execute_with_context(Account::new(), context, None);
let mut observations = Vec::new();
while let Some(event) = stream.next().await {
observations.push(event.unwrap().response_observation().unwrap().clone());
}
assert_eq!(observations.len(), 6);
assert_eq!(observations[0].timings().first_token_ms, None);
assert_eq!(observations[1].timings().first_token_ms, None);
let first_output = observations[2].timings().first_token_ms.unwrap();
assert!(first_output >= 50, "首字计时必须包括调用前的等待");
assert_eq!(
observations[2].timings().first_reasoning_ms,
Some(first_output)
);
assert!(observations[3].timings().first_text_ms.unwrap() >= first_output);
assert_eq!(observations[3].timings().upstream_response_ms, None);
let last = observations.last().unwrap();
assert_eq!(last.timings().first_token_ms, Some(41));
assert_eq!(last.timings().connect_ms, Some(5));
assert_eq!(last.timings().headers_ms, Some(8));
assert_eq!(last.timings().upstream_response_ms, Some(1000));
assert_eq!(last.timings().upstream_api_overhead_ms, Some(1.25));
assert_eq!(last.timings().upstream_engine_ms, Some(998.75));
assert_eq!(last.status_code(), Some(200));
assert_eq!(last.http_version().unwrap().as_str(), "HTTP/2");
assert_eq!(last.request_id().unwrap().as_str(), "upstream-494");
assert_eq!(last.upstream_response_model(), Some("real-model"));
assert_eq!(last.service_tier(), Some("priority"));
assert_eq!(
last.provider_metadata().unwrap().as_json(),
"{ \"future\": 1e3 }"
);
assert_eq!(last.client_headers().len(), 2);
assert_eq!(last.client_headers()[0].value().as_ref(), [255]);
assert_eq!(last.client_headers()[1].value().as_ref(), [254]);
drop(stream);
drop(generation);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn managed_http_preserves_byte_headers_trailers_and_version_for_plugins() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
for streaming in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let upstream = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
assert!(socket.read_buf(&mut request).await.unwrap() > 0);
}
socket.write_all(b"HTTP/1.1 200 OK\r\nX-Bytes: \xff\r\nX-Bytes: other\r\nTransfer-Encoding: chunked\r\nTrailer: x-tail\r\n\r\n").await.unwrap();
let body = if streaming { "data: {}\n\n" } else { "{}" };
socket
.write_all(format!("{:x}\r\n{body}\r\n", body.len()).as_bytes())
.await
.unwrap();
socket
.write_all(b"0\r\nx-tail: \xfe\r\nx-tail: final\r\n\r\n")
.await
.unwrap();
});
let records = tempfile::tempdir().unwrap();
let marker = records.path().join("http.jsonl");
let mut config = configuration(&base, streaming);
config["upstream_callbacks"][0]["body"] = json!("");
config["upstream_http_marker"] = json!(marker);
let (cache, _, runtime) = setup(config).await;
let generation =
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
.unwrap();
let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None);
while let Some(event) = stream.next().await {
event.unwrap();
}
upstream.await.unwrap();
let observations: Vec<Value> = std::fs::read_to_string(&marker)
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(observations[0]["http_version"], "HTTP/1.1");
let headers = observations[0]["headers"].as_array().unwrap();
assert!(headers.contains(&json!(["x-bytes", [255]])));
assert!(headers.contains(&json!(["x-bytes", b"other".to_vec()])));
let trailers = &observations.last().unwrap()["trailers"];
assert_eq!(
trailers,
&json!([["x-tail", [254]], ["x-tail", b"final".to_vec()]])
);
drop(stream);
drop(generation);
runtime.shutdown().await;
super::wait_until_empty(cache.path()).await;
}
}
#[tokio::test]
async fn adapter_accepts_service_tier_resolved_by_the_terminal_response() {
let server = MockServer::start().await;
@@ -466,8 +653,10 @@ async fn adapter_accepts_service_tier_resolved_by_the_terminal_response() {
.mount(&server)
.await;
let mut config = configuration(&server.uri(), false);
config["upstream_events"][0]["service_tier"] = json!("auto");
config["upstream_events"][1]["service_tier"] = json!("default");
config["upstream_events"][0]["observation"] =
json!({"transport":"http_json","service_tier":"auto"});
config["upstream_events"][1]["observation"] =
json!({"transport":"http_json","service_tier":"default"});
let (cache, _, runtime) = setup(config).await;
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
@@ -509,6 +698,7 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() {
let mut config = configuration(&server.uri(), false);
config["upstream_events"] = json!([{
"event": {"facts": []},
"observation":{"transport":"http_json","status_code":429,"timings":{"upstream_response_ms":230}},
"failure": {
"kind": "rate_limited", "status": 429, "retry_after_ms": 1500,
"message": "fixture rate limit", "code": "rate_limit_exceeded"
@@ -520,7 +710,7 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() {
.unwrap();
let account = Account::new();
let mut stream = execute(&runtime, &generation, Arc::clone(&account), "key-one", None);
let error = stream.next().await.unwrap().unwrap_err();
let mut error = stream.next().await.unwrap().unwrap_err();
assert_eq!(error.kind(), ProviderErrorKind::RateLimited);
assert_eq!(error.send_state(), UpstreamSendState::Sent);
assert_eq!(error.upstream_status(), Some(429));
@@ -529,6 +719,10 @@ async fn adapter_failure_preserves_upstream_diagnostics_and_native_feedback() {
"rate_limit_exceeded"
);
assert_eq!(error.retry_after(), Some(Duration::from_millis(1500)));
let observations = error.take_atomic_client_events();
let observation = observations[0].response_observation().unwrap();
assert_eq!(observation.status_code(), Some(429));
assert_eq!(observation.timings().upstream_response_ms, Some(230));
let snapshot = error.stable_snapshot();
let raw: Value = serde_json::from_str(snapshot.raw_upstream_error().unwrap().as_str()).unwrap();
assert_eq!(raw["message"], "fixture rate limit");
@@ -584,7 +778,8 @@ async fn adapter_overrides_auth_headers_and_rejects_stale_continuation() {
super::wait_until_empty(cache.path()).await;
let mut config = configuration(&server.uri(), false);
config["upstream_callbacks"][0]["params"]["headers"] = json!([["Authorization", "injected"]]);
config["upstream_callbacks"][0]["params"]["headers"] =
json!([["Authorization", b"injected".to_vec()]]);
let (cache, _, runtime) = setup(config).await;
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
@@ -636,8 +831,13 @@ async fn adapter_does_not_publish_completed_when_rpc_fails_after_terminal_frame(
.contains("fixture terminal fault")
);
assert!(!format!("{error:?}").contains("fixture terminal fault"));
let facts = error
.take_atomic_client_events()
let atomic = error.take_atomic_client_events();
assert!(
atomic
.iter()
.any(|event| event.response_observation().is_some())
);
let facts = atomic
.into_iter()
.flat_map(|event| event.into_parts().0)
.collect::<Vec<_>>();
@@ -726,11 +926,12 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle
let (socket, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_hdr_async(
socket,
|request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| {
|request: &tokio_tungstenite::tungstenite::handshake::server::Request, mut response: tokio_tungstenite::tungstenite::handshake::server::Response| {
assert_eq!(
request.headers()["authorization"],
"Bearer fixture-native-token"
);
response.headers_mut().append("x-bytes", http::HeaderValue::from_static("handshake-value"));
Ok(response)
},
)
@@ -745,7 +946,10 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle
}
assert!(ws.next().await.is_none_or(|result| result.is_err()));
});
let records = tempfile::tempdir().unwrap();
let marker = records.path().join("handshake.jsonl");
let mut config = configuration(&base, false);
config["upstream_http_marker"] = json!(marker);
config["upstream_registration"]["adapters"][0]["transport"] = json!("websocket");
config["upstream_events"][1]["continuation"]["scope"] = json!("connection_local");
config["upstream_callbacks"] = json!([
@@ -789,6 +993,22 @@ async fn websocket_continuation_reuses_exact_connection_and_consumes_each_handle
event.unwrap();
}
drop(second);
let handshakes: Vec<Value> = std::fs::read_to_string(&marker)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.filter(|value| value["status"] == 101)
.collect();
assert_eq!(handshakes.len(), 2);
assert_eq!(handshakes[0], handshakes[1]);
assert_eq!(handshakes[0]["http_version"], "HTTP/1.1");
assert!(
handshakes[0]["headers"]
.as_array()
.unwrap()
.contains(&json!(["x-bytes", b"handshake-value".to_vec()]))
);
let error = execute(
&runtime,
&generation,
@@ -48,7 +48,8 @@ fn host_compatibility_requires_the_trusted_middleware_contract() {
assert!(compatibility.supports_capability("middleware", 5));
assert!(!compatibility.supports_capability("upstream_adapter", 1));
assert!(!compatibility.supports_capability("upstream_adapter", 2));
assert!(compatibility.supports_capability("upstream_adapter", 3));
assert!(!compatibility.supports_capability("upstream_adapter", 3));
assert!(compatibility.supports_capability("upstream_adapter", 4));
assert!(!compatibility.supports_capability("openai", 1));
}
@@ -1683,6 +1683,7 @@ impl Peer {
}))
.await?;
for (callback, (result, payload)) in callbacks.iter().zip(results) {
self.append_observation_marker("upstream_http_marker", &result);
if let Some(stream) = result["stream"].as_str() {
response_body.clear();
let read_method = if callback["method"] == "host.http.do_stream" {
@@ -1699,6 +1700,7 @@ impl Peer {
vec![],
)
.await?;
self.append_observation_marker("upstream_http_marker", &result);
response_body.extend(chunk);
if result["eof"] == true {
break;
@@ -1715,7 +1717,9 @@ impl Peer {
let mut message = UpstreamAdapterEvent::new(
serde_json::from_value::<ExecutionEvent>(event["event"].clone()).unwrap(),
);
message.service_tier = event["service_tier"].as_str().map(str::to_owned);
message.observation = event
.get("observation")
.map(|value| serde_json::from_value(value.clone()).unwrap());
message.continuation = event
.get("continuation")
.map(|value| serde_json::from_value(value.clone()).unwrap());
+3 -3
View File
@@ -8,8 +8,8 @@ SDK 处于实验阶段;能力是否可用取决于宿主支持、清单声明
| --- | --- | --- |
| SDK 包 | `0.1.0` | Rust 开发依赖,独立于网关版本 |
| `manifestVersion` | `2` | 安装清单格式 |
| `package.protocolVersion` | `4` | 宿主与插件的进程通信 |
| `contributes.*.version` | `middleware` 为 `5`,`scheduler` / `upstream_adapter` 为 `3`,其余见声明 | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) |
| `package.protocolVersion` | `5` | 宿主与插件的进程通信 |
| `contributes.*.version` | `middleware` 为 `5`,`scheduler` 为 `3`,`upstream_adapter` 为 `4`,其余见声明 | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) |
开发顺序:编写[清单](docs/manifest.md) → 实现[能力处理器](docs/capabilities.md) → 用 [CLI](../../../apps/plugin-cli/README.md)
打包 → [安装、配置与启用](../../../../docs/plugins.md)。完整可运行示例位于独立仓库
@@ -92,7 +92,7 @@ sequenceDiagram
首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。
普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束
协议 v4 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
协议 v5 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付
@@ -221,8 +221,9 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新绑定才读取新配置,无需重新启用插件
`ModelExecuteRequest.client_key_id` 可显式选择执行 Key,不与触发插件的父请求 Key 求交。省略时继承现有模型父请求身份;没有模型父请求的调用必须提供 Key。所选 Key 的启用状态、模型规则、账号组、并发和预算继续由 Core 执行
嵌套调用与显式 Key 调用继承入口快照中的客户端 IP 和 User-Agent;没有 HTTP 入口的后台调用保持来源未知
模型事件使用 `call::model::ExecutionEvent` 的 `GPE2` 封套,将 canonical 事实、宿主事实和原始 wire 分段编码。`host` 包含费用及定价明细、完整上游观测、会话状态和改写来源;当前 wire 与原始 wire 都保留二进制字节。读取或回传快照不触发第二次计费、续接提交或发送状态更新。
模型事件使用 `call::model::ExecutionEvent` 的 `GPE2` 封套,将 canonical 事实、宿主事实和原始 wire 分段编码。`host` 包含费用及定价明细、完整上游观测、会话状态和改写来源;当前 wire 与原始 wire 都保留二进制字节;`WirePayload::WebSocket` 独立保留完整文本消息和可选 JSON 视图,不把未知文本改造成 SSE。读取或回传快照不触发第二次计费、续接提交或发送状态更新。
非流式模型结果通过 `ModelEventBatch` 的 `HME1` 有界二进制批次返回;流式句柄只在创建它的父调用内有效。
发起回调的插件实例在子调用中跳过,Core 在单次执行的子调用图内限制递归深度、总数和并发,避免插件调用自身形成环。
显式 Key 的身份快照随父调用保留,每个模型执行独立计时并建立调用图;父调用结束会取消尚未完成的执行,连接空闲不消耗后续执行的期限
@@ -237,6 +238,10 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。取消一次 `read()` 等待后,再次读取会继续同一次操作,不丢弃已经返回的分块。
账号上游使用同一正文对象,使用方式见[受管上游](upstream-adapters.md)
请求和响应 `headers` 使用 `(String, Vec<u8>)`,保留重复头与非 UTF-8 值;响应另有 `http_version`。
读取至 EOF 后通过 `body.trailers()` 取得完整尾部头,读取中只包含已收到的部分。
`collect` 只返回正文,需要尾部头时使用 `read`;底层非流式回复携带 `trailers`,流式回复在 `stream_read` 中交付尾部头
`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。
受管 HTTP(包括 `host.upstream.http.do[_stream]`)的请求正文按有效设置
`responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置;
@@ -247,7 +252,7 @@ attempt、路由、调度、重试和上游适配器使用执行冻结预算,
这里检查实际出站正文,内部模型请求的转义余量不增加出站预算。响应缓冲及流窗口不随请求配置扩大
普通调用使用父调用剩余期限;已建立的受管 HTTP 流或 WebSocket 会话按每次网络操作计时,仍受父资源取消约束
期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。错误消息提供分类提示,响应正文仍由插件读取;协议故障不应直接展示内部消息
期限耗尽返回 `timeout`,解析、连接或响应读取失败返回 `upstream`。错误 `details.kind` 和 `details.reason` 保留 Host 的故障分类与原因标记,响应正文仍由插件读取;协议故障不应直接展示内部消息
## 请求扩展
@@ -34,7 +34,7 @@
清单与宿主兼容声明的格式版本为 `2`,进程协议版本为 `4`;清单不接受 `permissions` 字段
RPC v4 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
RPC v5 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本;
数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。
@@ -49,7 +49,7 @@ RPC v4 的帧布局、编码与会话生命周期见[通信边界](../README.md#
## 扩展项简写
作者声明可以省略 `id` 和固定阶段。版本字段省略时为 `1`,适用于 `model_catalog`、`command_line` 和 `maintenance`;其余能力显式声明当前版本:`middleware` 为 `5`,`scheduler` 和 `upstream_adapter` 为 `3`,认证、路由、重试、观察和管理能力为 `2`:
作者声明可以省略 `id` 和固定阶段。版本字段省略时为 `1`,适用于 `model_catalog`、`command_line` 和 `maintenance`;其余能力显式声明当前版本:`middleware` 为 `5`,`scheduler` 为 `3`,`upstream_adapter` 为 `4`,认证、路由、重试、观察和管理能力为 `2`:
```json
{
@@ -1,12 +1,12 @@
# 受管上游适配器
`upstream_adapter` v3 是现有生成请求洋葱链的终端,只挂到内置 `openai` / `xai`,不替换独立图像或压缩端点。
`upstream_adapter` v4 是现有生成请求洋葱链的终端,只挂到内置 `openai` / `xai`,不替换独立图像或压缩端点。
插件定义 Base URL、业务路径、请求编码和响应解析;宿主继续持有身份、ACL、选号、租约、凭据、代理、重试和账本。
普通请求或响应加工使用 [middleware](capabilities.md#洋葱中间件)
## 声明与绑定
清单声明 `upstream_adapter`、版本 `3`,以及匹配的 `inputFormats` / `outputFormats`;固定阶段 `upstream` 由作者清单规范化入口生成。
清单声明 `upstream_adapter`、版本 `4`,以及匹配的 `inputFormats` / `outputFormats`;固定阶段 `upstream` 由作者清单规范化入口生成。
使用 `PluginBuilder::on` 注册
`methods::UPSTREAM_ADAPTER_REGISTER` 与 `methods::UPSTREAM_ADAPTER_EXECUTE`
@@ -55,15 +55,15 @@ while let Some(bytes) = body.read().await? {
正文与普通 `HostClient::http` 共用 `read`、`collect(maximum_bytes)` 和 `close`;每次读取至多 64 KiB,
不后台预读,不向作者暴露流 ID。提前关闭及收集超限会释放正文,父调用结束或取消时宿主兜底回收。
JSON 可有界收集,SSE 应增量解析,不能把读取块当成完整事件
JSON 可有界收集,SSE 应增量解析,不能把读取块当成完整事件。HTTP 版本、字节头及 trailers 的读取见[网络](capabilities.md#网络)
WebSocket 使用 `call.host.upstream_websocket(request).await?`,返回
`UpstreamWebSocketUpgrade::Connected { headers, connection }` 或 `Rejected { status, headers, body }`。
`UpstreamWebSocketUpgrade::Connected { http_version, headers, connection }` 或 `Rejected { status, http_version, headers, body, trailers }`。
连接提供 `send(kind, body)`、`read()`、`close()`,支持完整文本与二进制消息,Ping/Pong 由宿主处理。
读写可同时进行,等待上游输出时可以发送控制消息;有先后依赖的消息须顺序等待 `send`。
同一时刻只允许一个读取者;取消一次 `read()` 等待后,再次读取继续同一次接收,不跳过消息。
`close` 会唤醒在途操作并释放连接,并发关闭等待同一次完成;读取超时或连接错误也会关闭连接。
握手拒绝保留 HTTP 状态和正文。底层 `host.upstream.*` 是 SDK 使用的资源协议
握手拒绝保留 HTTP 状态、正文和 trailers;连接复用返回该连接最初的握手版本和响应头。底层 `host.upstream.*` 是 SDK 使用的资源协议
```rust,ignore
let (message, ()) = tokio::try_join!(
@@ -80,16 +80,28 @@ let (message, ()) = tokio::try_join!(
执行返回 `TypedReply<Empty>` 的有界 `ResponseStream`。每个 chunk 使用 `UpstreamAdapterEvent::encode()`,
包含标准事实与可选原始 wire;事件需符合开始、内容、工具、用量和唯一结束的顺序。
原始 JSON / SSE 字节与标准事实分开传递,错误使用 `UpstreamFailure`;宿主网络观测决定发送状态,插件不能将其降为未发送
原始 JSON / SSE / WebSocket 字节与标准事实分开传递,错误使用 `UpstreamFailure`;宿主网络观测决定发送状态,插件不能将其降为未发送
初始 RPC 回复受插件处理预算约束,默认上限为 120 秒;持续接收与解析应放在返回的流中。
初始流式回复后,流的寿命遵循模型请求期限与取消,受管 HTTP 正文每次读取单独计时。
持续有数据的流不会因初始 RPC 预算耗尽而结束,单次读取超时、客户端取消和流结束仍会回收资源
用量提交标准 `Usage`,费用由内置 Provider 按已冻结的上游模型与价格计算,响应回显模型不切换计价。
`service_tier` 记录上游回显的最新档位,允许上游在完成时确定实际值;OpenAI 计价沿用请求策略,回显档位仅作观测。
`observation.service_tier` 记录上游回显的最新档位,允许上游在完成时确定实际值;OpenAI 计价沿用请求策略,回显档位仅作观测。
未知用量或价格保留未知。宿主确认 RPC 正常结束后才发布完成事实,插件不能独立结算或组织换号重试
`UpstreamAdapterEvent.observation` 接收 `ResponseObservation` 原始观测:HTTP 版本、状态、请求 ID、回显模型、
服务档位、字节响应头、Provider JSON 元数据及 `ResponseTimings`。`transport` 与注册传输类型一致;
已提供字段覆盖同次执行的旧值,缺失字段或空 headers 保留已有观测,允许独立的观测事件及失败事件携带观测。
`ExecutionEvent.host` 是宿主结果的只读快照,不能用于回写费用、发送状态或续接
`first_*_ms` 相对 Core 请求计时起点,包含本次请求的排队和重试等待。
`request_elapsed_ms` 提供宿主发送本次适配器调用前已耗的时间,插件自行计时须对齐该基线;
没有报告时 Runtime 在收到首事件、非空正文/推理/工具增量时记录相应时间。
开始、心跳和空增量不构成首个输出,显式报告的计时优先于宿主缺省观测。
`upstream_response_ms`、`upstream_api_overhead_ms`、`upstream_engine_ms` 只能来自上游原始计时,
没有来源时保持缺失。观测合同不包含总耗时、速率或计算费用,这些由宿主业务层消费原始值计算
成功终态可附带 `UpstreamContinuation`,包含上游响应 ID、不超过 32 KiB 的私有续接材料和作用域:
- `persisted`:续接材料随宿主会话保存,仍受实例、代次、Key、账号和凭据版本限制
@@ -63,15 +63,17 @@ pub struct HttpRequest {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: Vec<(String, String)>,
pub headers: Vec<(String, Vec<u8>)>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub http_version: String,
pub headers: Vec<(String, Vec<u8>)>,
pub stream: Option<String>,
pub trailers: Vec<(String, Vec<u8>)>,
}
/// 宿主账号列表查询;cursor 是上次结果返回的稳定账号 ID
@@ -142,6 +142,7 @@ fn attach(target: Option<&mut Vec<u8>>, bytes: &[u8]) -> Result<(), ExecutionEnc
fn raw_wire(wire: Option<&mut super::WireEvent>) -> Option<&mut Vec<u8>> {
match &mut wire?.payload {
WirePayload::Json { raw_sse, .. } => raw_sse.as_mut(),
WirePayload::WebSocket { message, .. } => Some(message),
WirePayload::RawSse { frame } => Some(frame),
WirePayload::RawJson { body } | WirePayload::RawBody { body } => Some(body),
}
@@ -4,10 +4,7 @@
use serde::{Deserialize, Serialize};
use super::WireEvent;
use crate::call::{
middleware::MiddlewareHeader,
observation::{RequestMoney, RequestTimings},
};
use crate::call::{middleware::MiddlewareHeader, observation::RequestMoney};
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -76,12 +73,60 @@ pub struct ResponseObservation {
pub request_id: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
pub timings: RequestTimings,
#[serde(default)]
pub timings: ResponseTimings,
#[serde(default)]
pub client_headers: Vec<MiddlewareHeader>,
/// Provider 已产生的完整观测 JSON 文本,保留原始表示
pub provider_metadata: Option<String>,
}
/// 原始上游计时;不包含最终总耗时、速率或费用等业务结果
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ResponseTimings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport_decision_wait_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_event_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_reasoning_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_text_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_token_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_processing_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_response_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_api_overhead_ms: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_engine_ms: Option<f64>,
}
impl ResponseObservation {
#[must_use]
pub fn new(transport: impl Into<String>) -> Self {
Self {
transport: transport.into(),
http_version: None,
websocket_pool: None,
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ResponseTimings::default(),
client_headers: Vec::new(),
provider_metadata: None,
}
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionState {
@@ -158,6 +158,12 @@ pub enum WirePayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
raw_sse: Option<Vec<u8>>,
},
/// 完整 WebSocket 文本消息及可用的解析视图,原文独立放入二进制段
WebSocket {
event: Option<String>,
data: Option<serde_json::Value>,
message: Vec<u8>,
},
/// 注释、非 JSON data 等原生 SSE 表达,不能夹带第二个帧
RawSse { frame: Vec<u8> },
/// 一个完整的非流式 JSON 响应正文,保留空白与字段顺序
@@ -10,14 +10,14 @@ use crate::call::model::{
#[derive(Serialize, Deserialize)]
struct Metadata {
service_tier: Option<String>,
observation: Option<super::super::model::facts::ResponseObservation>,
continuation: Option<UpstreamContinuation>,
failure: Option<UpstreamFailure>,
}
pub(super) fn encode(value: UpstreamAdapterEvent) -> Result<Vec<u8>, ExecutionEncodingError> {
let metadata = Metadata {
service_tier: value.service_tier,
observation: value.observation,
continuation: value.continuation,
failure: value.failure,
};
@@ -28,7 +28,7 @@ pub(super) fn decode(bytes: &[u8]) -> Result<UpstreamAdapterEvent, ExecutionEnco
let (metadata, [event]): (Metadata, _) = unpack(*b"GPA1", bytes)?;
Ok(UpstreamAdapterEvent {
event: ExecutionEvent::decode(event)?,
service_tier: metadata.service_tier,
observation: metadata.observation,
continuation: metadata.continuation,
failure: metadata.failure,
})
@@ -95,6 +95,8 @@ pub struct UpstreamAdapterRequest {
pub client_transport: String,
/// 宿主冻结的 Fast 策略:default、enabled 或 disabled
pub fast_mode: String,
/// 宿主发送调用前相对 Core 请求计时起点已经过的毫秒数
pub request_elapsed_ms: u64,
pub headers: Vec<(String, Vec<u8>)>,
/// 只能来自同账号、同插件代次的宿主续接记录,不从客户端正文信任此值
pub continuation: Option<UpstreamContinuation>,
@@ -129,8 +131,8 @@ pub struct UpstreamContinuation {
/// 一个完整 GPE2 事件与可选的结算/续接事实;见 [`Self::encode`]
pub struct UpstreamAdapterEvent {
pub event: ExecutionEvent,
/// 上游回显的最新服务档位,可在终态确定;是否参与计价由内置 Provider 决定
pub service_tier: Option<String>,
/// 本次执行的上游观测;不使用 ExecutionEvent.host 回写宿主账本
pub observation: Option<super::model::facts::ResponseObservation>,
pub continuation: Option<UpstreamContinuation>,
pub failure: Option<UpstreamFailure>,
}
@@ -140,7 +142,7 @@ impl UpstreamAdapterEvent {
pub const fn new(event: ExecutionEvent) -> Self {
Self {
event,
service_tier: None,
observation: None,
continuation: None,
failure: None,
}
@@ -186,7 +188,7 @@ pub struct UpstreamFailure {
pub struct UpstreamHttpRequest {
pub method: String,
pub path: String,
pub headers: Vec<(String, String)>,
pub headers: Vec<(String, Vec<u8>)>,
#[serde(default)]
pub query: Vec<(String, String)>,
}
@@ -195,7 +197,7 @@ pub struct UpstreamHttpRequest {
#[serde(deny_unknown_fields)]
pub struct UpstreamWebSocketRequest {
pub path: String,
pub headers: Vec<(String, String)>,
pub headers: Vec<(String, Vec<u8>)>,
#[serde(default)]
pub query: Vec<(String, String)>,
}
@@ -32,7 +32,8 @@ impl Capability {
| Self::RetryPolicy
| Self::Observer
| Self::Management => &[2],
Self::Scheduler | Self::UpstreamAdapter => &[3],
Self::Scheduler => &[3],
Self::UpstreamAdapter => &[4],
Self::ModelCatalog | Self::CommandLine | Self::Maintenance => &[1],
}
}
@@ -11,7 +11,8 @@ use crate::{
/// 响应头与惰性正文;不包含宿主的内部流句柄
pub struct HostHttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub http_version: String,
pub headers: Vec<(String, Vec<u8>)>,
pub body: HostHttpBody,
}
@@ -20,6 +21,7 @@ pub struct HostHttpBody {
host: HostClient,
stream: Option<String>,
upstream: bool,
trailers: Vec<(String, Vec<u8>)>,
read: PendingRead<Result<HostReply, SessionError>>,
}
@@ -64,11 +66,13 @@ impl HostClient {
}
Ok(HostHttpResponse {
status: response.status,
http_version: response.http_version,
headers: response.headers,
body: HostHttpBody {
host: self.clone(),
stream: response.stream,
upstream,
trailers: response.trailers,
read: PendingRead::default(),
},
})
@@ -76,6 +80,12 @@ impl HostClient {
}
impl HostHttpBody {
/// 已收到的响应尾部头;读取至 EOF 后完整,名称、多值和字节原样保留
#[must_use]
pub fn trailers(&self) -> &[(String, Vec<u8>)] {
&self.trailers
}
/// 按需读取至多 64 KiB,EOF 后再次读取不再调用宿主
/// 取消一次等待后,再次调用会继续同一次读取,不丢弃分块
///
@@ -89,6 +99,8 @@ impl HostHttpBody {
#[serde(deny_unknown_fields)]
struct Read {
eof: bool,
#[serde(default)]
trailers: Vec<(String, Vec<u8>)>,
}
let maximum_bytes = u32::try_from(self.host.maximum_stream_chunk_bytes().min(64 * 1024))
.map_err(|_| invalid())?;
@@ -112,6 +124,7 @@ impl HostHttpBody {
let _ = self.close().await;
return Err(invalid());
}
self.trailers.extend(result.trailers);
if result.eof {
self.stream = None;
Ok(None)
@@ -21,13 +21,16 @@ use crate::{
/// 握手拒绝仍保留 HTTP 状态及正文,供适配器解析上游错误
pub enum UpstreamWebSocketUpgrade {
Connected {
headers: Vec<(String, String)>,
http_version: String,
headers: Vec<(String, Vec<u8>)>,
connection: UpstreamWebSocket,
},
Rejected {
status: u16,
headers: Vec<(String, String)>,
http_version: String,
headers: Vec<(String, Vec<u8>)>,
body: Vec<u8>,
trailers: Vec<(String, Vec<u8>)>,
},
}
@@ -57,6 +60,7 @@ impl HostClient {
}
Ok(if response.status == 101 {
UpstreamWebSocketUpgrade::Connected {
http_version: response.http_version,
headers: response.headers,
connection: UpstreamWebSocket {
host: self.clone(),
@@ -67,8 +71,10 @@ impl HostClient {
} else {
UpstreamWebSocketUpgrade::Rejected {
status: response.status,
http_version: response.http_version,
headers: response.headers,
body,
trailers: response.trailers,
}
})
}
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::{CallContext, Handshake, PluginFault};
pub const PROTOCOL_VERSION: u32 = 4;
pub const PROTOCOL_VERSION: u32 = 5;
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64
#[derive(Clone, PartialEq, Serialize, Deserialize)]
@@ -11,7 +11,8 @@ fn request() -> UpstreamAdapterRequest {
"adapter_id":"example", "provider":"openai", "upstream_model":"fixture",
"client_key_id":"key", "account_id":"account", "credential_revision":1,
"protocol":"openai", "client_transport":"http_json", "fast_mode":"default",
"headers":[["x-fixture",[97,98]]], "continuation":null
"headers":[["x-fixture",[97,98]]], "continuation":null,
"request_elapsed_ms":0
}))
.unwrap()
}
@@ -108,10 +108,23 @@ impl PluginHandler for NetworkHandler {
if call.params["collect"] == true {
return Ok(CallReply::unary(json!({}), response.body.collect(3).await?));
}
if call.params["metadata"] == true {
assert_eq!(response.http_version, "HTTP/1.1");
assert_eq!(
response.headers,
vec![
("x-bytes".to_owned(), vec![0xff]),
("x-bytes".to_owned(), b"other".to_vec())
]
);
}
let mut body = response.body;
let bytes = body.read().await?.unwrap();
assert!(body.read().await?.is_none());
assert!(body.read().await?.is_none());
if call.params["metadata"] == true {
assert_eq!(body.trailers(), &[("x-tail".to_owned(), vec![0xfe])]);
}
body.close().await?;
body.close().await?;
Ok(CallReply::unary(json!({}), bytes))
@@ -146,7 +159,14 @@ async fn reply_callback(
async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
for method in ["http", "upstream"] {
let (mut host, task) = start_session(NetworkHandler).await;
send_call(&mut host, 1, method, json!({}), b"request".to_vec()).await;
send_call(
&mut host,
1,
method,
json!({"metadata":true}),
b"request".to_vec(),
)
.await;
let prefix = if method == "http" {
"host.http"
} else {
@@ -155,7 +175,7 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
let frame = reply_callback(
&mut host,
&format!("{prefix}.do_stream"),
json!({"status":200,"headers":[],"stream":"private-handle"}),
json!({"status":200,"http_version":"HTTP/1.1","trailers":[],"headers":[["x-bytes",[255]],["x-bytes",b"other".to_vec()]],"stream":"private-handle"}),
vec![],
)
.await;
@@ -173,7 +193,7 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
reply_callback(
&mut host,
&format!("{prefix}.stream_read"),
json!({"eof":true}),
json!({"eof":true,"trailers":[["x-tail",[254]]]}),
vec![],
)
.await;
@@ -191,7 +211,7 @@ async fn bounded_collection_closes_the_host_stream_before_reporting_overflow() {
reply_callback(
&mut host,
"host.upstream.http.do_stream",
json!({"status":200,"headers":[],"stream":"private-handle"}),
json!({"status":200,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":"private-handle"}),
vec![],
)
.await;
@@ -222,7 +242,7 @@ async fn websocket_session_handles_messages_eof_and_http_rejection() {
reply_callback(
&mut host,
"host.upstream.websocket.open",
json!({"status":101,"headers":[],"stream":null}),
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
vec![],
)
.await;
@@ -249,7 +269,7 @@ async fn websocket_session_handles_messages_eof_and_http_rejection() {
reply_callback(
&mut host,
"host.upstream.websocket.open",
json!({"status":401,"headers":[],"stream":null}),
json!({"status":401,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
b"unauthorized".to_vec(),
)
.await;
@@ -268,7 +288,7 @@ async fn websocket_send_and_read_callbacks_can_be_in_flight_together() {
reply_callback(
&mut host,
"host.upstream.websocket.open",
json!({"status":101,"headers":[],"stream":null}),
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
vec![],
)
.await;
@@ -329,7 +349,7 @@ async fn websocket_close_can_be_retried_after_cancelling_its_future() {
reply_callback(
&mut host,
"host.upstream.websocket.open",
json!({"status":101,"headers":[],"stream":null}),
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
vec![],
)
.await;
@@ -362,7 +382,7 @@ async fn websocket_close_does_not_wait_for_a_pending_reader() {
reply_callback(
&mut host,
"host.upstream.websocket.open",
json!({"status":101,"headers":[],"stream":null}),
json!({"status":101,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":null}),
vec![],
)
.await;
@@ -105,7 +105,7 @@ async fn verify_cancelled_read(method: &str) {
reply(
&mut host,
id,
json!({"status":status,"headers":[],"stream":stream}),
json!({"status":status,"http_version":"HTTP/1.1","trailers":[],"headers":[],"stream":stream}),
b"",
)
.await;
@@ -135,7 +135,7 @@ fn capability_contract_versions_are_checked_independently_of_rpc_version() {
for (capability, stage, supported, needs_formats) in [
(Capability::Middleware, Stage::Request, 5, true),
(Capability::Management, Stage::Management, 2, false),
(Capability::UpstreamAdapter, Stage::Upstream, 3, true),
(Capability::UpstreamAdapter, Stage::Upstream, 4, true),
(Capability::Scheduler, Stage::Scheduling, 3, false),
] {
for mut manifest in [source_manifest(), packaged_manifest()] {
@@ -396,7 +396,7 @@ fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() {
let handshake = format!(
r#"{{
"protocol_version":4,"artifact_sha256":"digest","plugin_id":"test.example",
"protocol_version":5,"artifact_sha256":"digest","plugin_id":"test.example",
"instance_id":"instance","generation":1,"incarnation":"incarnation",
"contributes":{{"middleware":{declaration},"middleware":{declaration}}}
}}"#