diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index b5747c284..9dc2d2b5d 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -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(¤t); diff --git a/backend/crates/gateway-core/src/engine/execution/contract.rs b/backend/crates/gateway-core/src/engine/execution/contract.rs index 138995ea1..0a234884d 100644 --- a/backend/crates/gateway-core/src/engine/execution/contract.rs +++ b/backend/crates/gateway-core/src/engine/execution/contract.rs @@ -65,6 +65,22 @@ pub struct ExecutionRequestMetadata { pub previous_response_id: Option, } +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, diff --git a/backend/crates/gateway-core/src/engine/execution/service.rs b/backend/crates/gateway-core/src/engine/execution/service.rs index 8075fbf57..464e0687f 100644 --- a/backend/crates/gateway-core/src/engine/execution/service.rs +++ b/backend/crates/gateway-core/src/engine/execution/service.rs @@ -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( diff --git a/backend/crates/gateway-core/src/event.rs b/backend/crates/gateway-core/src/event.rs index 4ab98ccc1..fcf0fa920 100644 --- a/backend/crates/gateway-core/src/event.rs +++ b/backend/crates/gateway-core/src/event.rs @@ -547,7 +547,7 @@ pub struct ProviderResponseTimings { /// 这样 Provider 专有协议不会渗入路由 /// 或管理领域 #[derive(Clone, PartialEq, Eq)] -pub struct ProviderResponseMetadata(String); +pub struct ProviderResponseMetadata(Arc); impl ProviderResponseMetadata { const MAX_BYTES: usize = 32 * 1024; @@ -561,7 +561,7 @@ impl ProviderResponseMetadata { serde_json::from_str::(&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, upstream_response_model: Option, timings: ProviderResponseTimings, - client_headers: Vec, + // 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合 + client_headers: Arc<[ProviderResponseHeader]>, provider_metadata: Option, } @@ -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) -> Self { - self.client_headers = client_headers; + self.client_headers = client_headers.into(); self } diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index 7d73b1464..950831c98 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -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); } diff --git a/backend/crates/gateway-core/tests/engine/execution/mod.rs b/backend/crates/gateway-core/tests/engine/execution/mod.rs index 3355865b4..c44c83a65 100644 --- a/backend/crates/gateway-core/tests/engine/execution/mod.rs +++ b/backend/crates/gateway-core/tests/engine/execution/mod.rs @@ -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]).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 { diff --git a/backend/crates/gateway-core/tests/event.rs b/backend/crates/gateway-core/tests/event.rs index 01c0a0f8e..1733e42b1 100644 --- a/backend/crates/gateway-core/tests/event.rs +++ b/backend/crates/gateway-core/tests/event.rs @@ -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)); +} diff --git a/backend/crates/gateway-host/src/outbound/http.rs b/backend/crates/gateway-host/src/outbound/http.rs index 910e98c3e..5a6ebed48 100644 --- a/backend/crates/gateway-host/src/outbound/http.rs +++ b/backend/crates/gateway-host/src/outbound/http.rs @@ -46,6 +46,7 @@ pub struct HttpRequest { pub struct HttpResponse { pub status: u16, + pub version: http::Version, pub headers: Vec<(String, Vec)>, pub body: HttpBody, } @@ -53,6 +54,7 @@ pub struct HttpResponse { pub struct HttpBody { response: hyper::body::Incoming, pending: Bytes, + trailers: Vec<(String, Vec)>, 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)> { + std::mem::take(&mut self.trailers) + } + pub async fn read(&mut self, maximum: usize) -> Result, 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")), diff --git a/backend/crates/gateway-host/src/outbound/websocket.rs b/backend/crates/gateway-host/src/outbound/websocket.rs index c30c417c4..7265713b4 100644 --- a/backend/crates/gateway-host/src/outbound/websocket.rs +++ b/backend/crates/gateway-host/src/outbound/websocket.rs @@ -27,6 +27,8 @@ type Socket = WebSocketStream>; /// 读写分别串行,允许等待输出时发送控制消息;关闭会唤醒在途操作并释放容量 pub struct ManagedWebSocket { + handshake_version: http::Version, + handshake_headers: Vec<(String, Vec)>, writer: Mutex, OwnedSemaphorePermit)>>, reader: Mutex>>, closed: watch::Sender, @@ -39,6 +41,7 @@ pub enum WebSocketMessage { pub struct WebSocketResponse { pub status: u16, + pub version: http::Version, pub headers: Vec<(String, Vec)>, pub connection: Option, pub body: Option, @@ -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)] { + &self.handshake_headers + } + pub async fn send( &self, message: WebSocketMessage, diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index aa469fbb2..4c26ac476 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -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] }, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs index a35b9d530..c5f2389e4 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/event.rs @@ -24,7 +24,7 @@ use gateway_protocol::openai::sse::SseEventDecoder; pub(super) struct EventDecoder { sequence: EventSequenceValidator, response_id: Option, - service_tier: Option, + observation: Option, 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 { 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)) diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs index bfb60f44e..541c86e0a 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs @@ -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() diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs index 57c38833e..b95566b68 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs @@ -3,6 +3,7 @@ mod event; mod execution; mod failure; +mod observation; mod registration; pub(crate) mod target; diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/observation.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/observation.rs new file mode 100644 index 000000000..9549a1096 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/observation.rs @@ -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 { + // 传输类型由已选适配器固定,不能让不一致的报告绕过 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) +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/facts.rs b/backend/crates/gateway-plugin/runtime/src/callback/facts.rs index cc12d9498..d10929a9a 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/facts.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/facts.rs @@ -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 { 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 { 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, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http.rs b/backend/crates/gateway-plugin/runtime/src/callback/http.rs index 24a950353..206435f98 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/http.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/http.rs @@ -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::, _>>() - .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, diff --git a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs index 66154e733..8b0308466 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs @@ -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::, _>>() - .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, diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs index 22c595ffb..5c9366196 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/facts/mod.rs @@ -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::(original).is_ok() + ); + } drop(stream); runtime.shutdown().await; } diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs index 3c1ce5ba5..c546995e4 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs @@ -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 = 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::>(); @@ -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 = std::fs::read_to_string(&marker) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(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, diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs index bd892c43b..5e59fa96f 100644 --- a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -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)); } diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs index 4a4245edf..a1607c334 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs @@ -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::(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()); diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index 89a218565..7f1c03289 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -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,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付 diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index 43f9aeeae..fb92115f1 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -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)`,保留重复头与非 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 的故障分类与原因标记,响应正文仍由插件读取;协议故障不应直接展示内部消息 ## 请求扩展 diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index e9ed902e7..96dda0378 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -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 { diff --git a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md index c6fa994bd..bfebde7a2 100644 --- a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md +++ b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md @@ -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` 的有界 `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、账号和凭据版本限制 diff --git a/backend/crates/gateway-plugin/sdk/src/call/host.rs b/backend/crates/gateway-plugin/sdk/src/call/host.rs index 0ca79fa39..8b9316035 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/host.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/host.rs @@ -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)>, } #[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)>, pub stream: Option, + pub trailers: Vec<(String, Vec)>, } /// 宿主账号列表查询;cursor 是上次结果返回的稳定账号 ID diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs b/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs index 4c1228d7f..66c2f65a8 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/codec.rs @@ -142,6 +142,7 @@ fn attach(target: Option<&mut Vec>, bytes: &[u8]) -> Result<(), ExecutionEnc fn raw_wire(wire: Option<&mut super::WireEvent>) -> Option<&mut Vec> { 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), } diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/facts.rs b/backend/crates/gateway-plugin/sdk/src/call/model/facts.rs index 8957f8b9b..63e327672 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/facts.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/facts.rs @@ -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, pub service_tier: Option, pub upstream_response_model: Option, - pub timings: RequestTimings, + #[serde(default)] + pub timings: ResponseTimings, + #[serde(default)] pub client_headers: Vec, /// Provider 已产生的完整观测 JSON 文本,保留原始表示 pub provider_metadata: Option, } +/// 原始上游计时;不包含最终总耗时、速率或费用等业务结果 +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_event_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_reasoning_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_text_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_token_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_processing_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_response_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_api_overhead_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_engine_ms: Option, +} + +impl ResponseObservation { + #[must_use] + pub fn new(transport: impl Into) -> 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 { diff --git a/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs index 0013e6e97..d258b612f 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/model/mod.rs @@ -158,6 +158,12 @@ pub enum WirePayload { #[serde(default, skip_serializing_if = "Option::is_none")] raw_sse: Option>, }, + /// 完整 WebSocket 文本消息及可用的解析视图,原文独立放入二进制段 + WebSocket { + event: Option, + data: Option, + message: Vec, + }, /// 注释、非 JSON data 等原生 SSE 表达,不能夹带第二个帧 RawSse { frame: Vec }, /// 一个完整的非流式 JSON 响应正文,保留空白与字段顺序 diff --git a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs index 973e5425b..202a53dd1 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs @@ -10,14 +10,14 @@ use crate::call::model::{ #[derive(Serialize, Deserialize)] struct Metadata { - service_tier: Option, + observation: Option, continuation: Option, failure: Option, } pub(super) fn encode(value: UpstreamAdapterEvent) -> Result, 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)>, /// 只能来自同账号、同插件代次的宿主续接记录,不从客户端正文信任此值 pub continuation: Option, @@ -129,8 +131,8 @@ pub struct UpstreamContinuation { /// 一个完整 GPE2 事件与可选的结算/续接事实;见 [`Self::encode`] pub struct UpstreamAdapterEvent { pub event: ExecutionEvent, - /// 上游回显的最新服务档位,可在终态确定;是否参与计价由内置 Provider 决定 - pub service_tier: Option, + /// 本次执行的上游观测;不使用 ExecutionEvent.host 回写宿主账本 + pub observation: Option, pub continuation: Option, pub failure: Option, } @@ -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)>, #[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)>, #[serde(default)] pub query: Vec<(String, String)>, } diff --git a/backend/crates/gateway-plugin/sdk/src/capability.rs b/backend/crates/gateway-plugin/sdk/src/capability.rs index aa36593d5..601779c6f 100644 --- a/backend/crates/gateway-plugin/sdk/src/capability.rs +++ b/backend/crates/gateway-plugin/sdk/src/capability.rs @@ -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], } } diff --git a/backend/crates/gateway-plugin/sdk/src/client/http.rs b/backend/crates/gateway-plugin/sdk/src/client/http.rs index 10d78dfb1..972ac81ed 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/http.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/http.rs @@ -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)>, pub body: HostHttpBody, } @@ -20,6 +21,7 @@ pub struct HostHttpBody { host: HostClient, stream: Option, upstream: bool, + trailers: Vec<(String, Vec)>, read: PendingRead>, } @@ -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)] { + &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)>, } 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) diff --git a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs index 7290e0759..6ac15c48a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs @@ -21,13 +21,16 @@ use crate::{ /// 握手拒绝仍保留 HTTP 状态及正文,供适配器解析上游错误 pub enum UpstreamWebSocketUpgrade { Connected { - headers: Vec<(String, String)>, + http_version: String, + headers: Vec<(String, Vec)>, connection: UpstreamWebSocket, }, Rejected { status: u16, - headers: Vec<(String, String)>, + http_version: String, + headers: Vec<(String, Vec)>, body: Vec, + trailers: Vec<(String, Vec)>, }, } @@ -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, } }) } diff --git a/backend/crates/gateway-plugin/sdk/src/message.rs b/backend/crates/gateway-plugin/sdk/src/message.rs index 55b390c5e..223847c54 100644 --- a/backend/crates/gateway-plugin/sdk/src/message.rs +++ b/backend/crates/gateway-plugin/sdk/src/message.rs @@ -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)] diff --git a/backend/crates/gateway-plugin/sdk/tests/call/upstream_adapter/codec.rs b/backend/crates/gateway-plugin/sdk/tests/call/upstream_adapter/codec.rs index 8e012a396..8d0566def 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/upstream_adapter/codec.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/upstream_adapter/codec.rs @@ -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() } diff --git a/backend/crates/gateway-plugin/sdk/tests/client/http.rs b/backend/crates/gateway-plugin/sdk/tests/client/http.rs index 5c8e2fee8..7afd32dfc 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/http.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/http.rs @@ -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; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/read.rs b/backend/crates/gateway-plugin/sdk/tests/client/read.rs index 6ea609be8..bcbf3136e 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/read.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/read.rs @@ -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; diff --git a/backend/crates/gateway-plugin/sdk/tests/manifest.rs b/backend/crates/gateway-plugin/sdk/tests/manifest.rs index 45bca3dcc..42d460311 100644 --- a/backend/crates/gateway-plugin/sdk/tests/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/tests/manifest.rs @@ -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}}} }}"# diff --git a/modules/plugins b/modules/plugins index 750073216..28199b811 160000 --- a/modules/plugins +++ b/modules/plugins @@ -1 +1 @@ -Subproject commit 7500732163f6a6ca0702235390d130963cd001ca +Subproject commit 28199b8114d23875ad92278d3857a2903badcd48