refactor(ws): structured terminal observation without SSE text round-trips

评审第 5 条:Responses WebSocket 收到的本来就是结构化协议事件,但为了复用面向
SSE 的 push_line,观测路径要先把每个事件序列化成 data: {json}\n\n,解析器再
decode 回 Value——一次纯粹的往返。这个「伪 SSE」形状是随手拼的,一旦拼装函数
以后被加上换行或分块逻辑,观测结果就会和真实事件悄悄分叉。

aether-ai-formats:
- OpenAIResponsesProviderState::push_line 机械拆成 decode + push_event,
  push_line 现在只做解码。协议状态机一行未动,diff 里除函数签名外只有
  &value → value(value 从拥有改成借用,持有结构化事件的传输不必为了调用它
  先克隆一份)。
- StreamingStandardTerminalObserver::push_event 走 TerminalStreamParser::Standard,
  service tier 的记录方式与 push_line 完全相同。openai:image 的终态状态机按 SSE
  行做增量解析、没有结构化入口,返回 AiSurfaceFinalizeError 让调用方
  disable_with_error 标记 parser_error,而不是静默丢事件、把摘要留成「未观察到
  终态」。ProviderStreamParser 的其余三个格式同样返回 Err:机械拆分随时可做,
  但不建无调用方的接口。

WS 侧:
- 新增 responses/observation.rs 的 ResponsesStructuredTerminalObserver,直接消费
  frame.protocol_events() 借出的事件。包一层的意义是让「不再拼 SSE」成为类型层面
  的事实——这个类型没有任何接受字节的方法,改回 push_line 不可能悄悄发生。
  finish() 里的 Ok(None) / Err → disable_with_error 兜底也一并收进来。
- body capture 不动,仍然是 SSE 形状(data: 开头、\n\n 结尾):
  aether_usage_runtime::report 用 line.strip_prefix("data:") 解析被捕获的 body
  判定 StreamCapturedTerminalState,而它是 stream_report_represents_failure 的一个
  OR 项,换成结构化 JSON 会让终态判定恒为 Missing。capture_sse_event /
  capture_client_frame / websocket_event_as_sse_line 全部保留,原因写在模块文档
  注释里。这一层只换观测,不换捕获。

差分测试(8 个,aether-ai-formats):同一组事件序列分别走 push_line 与
push_event,断言 ExecutionStreamTerminalSummary 完全相等——批量 delta 序列、
completed 带 usage、合法 incomplete、error、response.failed、未知事件、
service tier、缺终态;外加 openai:image 拒绝结构化入口。两条入口不可能有
过滤差异:任何 Value 序列化出来都不会命中 decode_json_data_line 的 empty /
":" / "event:" / [DONE] 四个过滤条件。

turn.rs 里三个既有的 WS 观测测试改走结构化入口;SSE 形状的断言留在 capture 一侧。
验收:crates/aether-usage 零 diff。
This commit is contained in:
AAEE86
2026-08-17 14:53:33 +08:00
committed by ZheFox
parent 59e27524da
commit 1d3051cb89
5 changed files with 515 additions and 61 deletions
@@ -14,6 +14,7 @@ mod client;
mod connection;
mod frame;
mod lifecycle;
mod observation;
mod quota;
mod redaction;
mod relay_policy;
@@ -0,0 +1,156 @@
//! Responses WebSocket 的终态观测入口。
//!
//! 这条传输收到的本来就是结构化的 Responses 协议事件。之前为了复用面向 SSE 的
//! `push_line`,观测路径要先把每个事件序列化成 `data: {json}\n\n`,解析器再把它
//! 解码回 `Value`——一次纯粹的往返,而且这个「伪 SSE」形状是随手拼的,一旦
//! 上游事件里出现需要转义的内容,或者以后有人给拼装函数加了换行/分块逻辑,
//! 观测结果就会和真实事件悄悄分叉。
//!
//! 现在观测走 [`StreamingStandardTerminalObserver::push_event`],直接吃
//! `frame.protocol_events()` 借出的事件,不再序列化、不再解码。
//!
//! **body capture 不走这条路,仍然保持 SSE 形状**`data: {json}\n\n`):
//! `aether_usage_runtime::report` 用 `line.strip_prefix("data:")` 解析被捕获的
//! body 来判定 `StreamCapturedTerminalState`,而它是 `stream_report_represents_failure`
//! 的一个 OR 项。把捕获内容换成结构化 JSON 会让终态判定恒为 Missing。
//! 也就是说这一层只换「观测」,不换「捕获」——见
//! [`super::turn::ResponsesProviderAttempt::capture_client_frame`] 一侧仍在用
//! SSE 编码。
use serde_json::Value;
use crate::ai_serving::api::StreamingStandardTerminalObserver;
use aether_contracts::ExecutionStreamTerminalSummary;
/// 包一层 [`StreamingStandardTerminalObserver`],只暴露结构化入口。
///
/// 存在的意义是让「WS 不再拼 SSE」成为类型层面的事实:这里没有任何接受字节的
/// 方法,所以不可能有人不小心把观测路径改回 `push_line`。
#[derive(Default)]
pub(super) struct ResponsesStructuredTerminalObserver {
inner: StreamingStandardTerminalObserver,
}
impl ResponsesStructuredTerminalObserver {
/// 观测一帧里的全部协议事件。
///
/// 第一个被拒绝的事件就停止推进并把摘要标成 parser_error:解析器的状态机是
/// 有顺序的,跳过一个事件继续喂后面的只会得到更没意义的摘要。
pub(super) fn observe_events(&mut self, report_context: &Value, events: &[&Value]) {
for event in events {
if let Err(error) = self.inner.push_event(report_context, event) {
self.inner.disable_with_error(error.to_string());
break;
}
}
}
pub(super) fn disable_with_error(&mut self, parser_error: impl Into<String>) {
self.inner.disable_with_error(parser_error);
}
pub(super) fn finish(&mut self, report_context: &Value) -> ExecutionStreamTerminalSummary {
match self.inner.finish(report_context) {
Ok(Some(summary)) => summary,
Ok(None) => ExecutionStreamTerminalSummary::default(),
Err(error) => {
self.inner.disable_with_error(error.to_string());
self.inner.latest_summary().cloned().unwrap_or_default()
}
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::ResponsesStructuredTerminalObserver;
fn report_context() -> serde_json::Value {
json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
})
}
#[test]
fn structured_events_reach_the_terminal_summary_without_sse_text() {
let context = report_context();
let created =
json!({"type": "response.created", "response": {"id": "resp_ws", "model": "gpt-5-codex"}});
let completed = json!({
"type": "response.completed",
"response": {
"id": "resp_ws",
"model": "gpt-5-codex",
"status": "completed",
"usage": {"input_tokens": 9, "output_tokens": 4, "total_tokens": 13},
},
});
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&context, &[&created, &completed]);
let summary = observer.finish(&context);
assert!(summary.observed_finish);
assert_eq!(summary.response_id.as_deref(), Some("resp_ws"));
let usage = summary
.standardized_usage
.as_ref()
.expect("a completed response carries usage");
assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.output_tokens, 4);
assert!(summary.parser_error.is_none());
}
/// 批量帧里的多个事件按顺序喂入,usage 不能因为批量而丢失。
#[test]
fn a_batched_frame_keeps_the_usage_of_its_last_event() {
let context = report_context();
let events = [
json!({"type": "response.created", "response": {"id": "resp_ws", "model": "m"}}),
json!({
"type": "response.output_text.delta",
"item_id": "msg",
"output_index": 0,
"content_index": 0,
"delta": "hi",
}),
json!({
"type": "response.completed",
"response": {
"id": "resp_ws",
"model": "m",
"status": "completed",
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4},
},
}),
];
let borrowed: Vec<&serde_json::Value> = events.iter().collect();
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&context, &borrowed);
let summary = observer.finish(&context);
let usage = summary
.standardized_usage
.as_ref()
.expect("usage survives batching");
assert_eq!(usage.input_tokens, 3);
assert_eq!(usage.output_tokens, 1);
assert_eq!(usage.dimensions.get("total_tokens"), Some(&json!(4)));
}
#[test]
fn a_disabled_observer_reports_the_parser_error() {
let context = report_context();
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.disable_with_error("upstream event was not valid JSON");
let summary = observer.finish(&context);
assert_eq!(
summary.parser_error.as_deref(),
Some("upstream event was not valid JSON")
);
}
}
@@ -33,13 +33,13 @@ use tracing::warn;
use super::adapter::ResponsesWebSocketProtocolAdapter;
use super::admission::ResponsesWebSocketTurnAdmission;
use super::frame::ParsedResponsesWebSocketFrame;
use super::observation::ResponsesStructuredTerminalObserver;
use super::settlement::attempt_facts_for_outcome;
use crate::execution_runtime::attempt_lifecycle::{
attempt_billing_is_void, AttemptBodyCapture, AttemptClientDelivery, AttemptLifecycleSeed,
AttemptProviderOutcome, AttemptStageGuard, AttemptTerminalFacts, AttemptTerminalFactsInput,
ExecutionAttemptLifecycle,
};
use crate::ai_serving::api::StreamingStandardTerminalObserver;
use crate::ai_serving::{build_openai_responses_stream_plan_from_decision, AiExecutionDecision};
use crate::clock::current_unix_ms;
use crate::control::{
@@ -227,7 +227,7 @@ pub(super) struct ResponsesProviderAttempt {
lifecycle: ExecutionAttemptLifecycle,
started_at: Instant,
provider_headers: BTreeMap<String, String>,
observer: StreamingStandardTerminalObserver,
observer: ResponsesStructuredTerminalObserver,
provider_capture: AttemptBodyCapture,
client_capture: AttemptBodyCapture,
upstream_bytes: u64,
@@ -414,7 +414,7 @@ pub(super) async fn begin_responses_websocket_turn(
lifecycle,
started_at: Instant::now(),
provider_headers: BTreeMap::new(),
observer: StreamingStandardTerminalObserver::default(),
observer: ResponsesStructuredTerminalObserver::default(),
provider_capture: AttemptBodyCapture::default(),
client_capture: AttemptBodyCapture::default(),
upstream_bytes: 0,
@@ -572,12 +572,13 @@ impl ResponsesProviderAttempt {
self.first_event_elapsed_ms = Some(elapsed_ms(self.started_at));
}
// A batched frame carries several events; the usage observer parses one
// Responses event per SSE line, so the batch must be unwrapped or its
// token usage is lost.
// 一帧可以带多个协议事件(批量帧),必须拆开:观测器按事件推进状态机,
// 整帧当一个事件喂会丢掉批量里最后那个 completed 的 usage。
let events = frame.protocol_events();
let mut report_context = self.lifecycle.take_report_context();
for event in &events {
// 观测已经走结构化入口,但捕获仍然必须是 SSE 形状:usage runtime 按
// `data:` 行解析被捕获的 body 判定终态。
self.capture_sse_event(event);
adapter.decorate_turn_report_context(&mut report_context, event);
}
@@ -587,15 +588,7 @@ impl ResponsesProviderAttempt {
"client_api_format": "openai:responses",
});
let report_context = self.lifecycle.report_context().unwrap_or(&fallback_context);
for event in &events {
if let Err(error) = self
.observer
.push_line(report_context, websocket_event_as_sse_line(event))
{
self.observer.disable_with_error(error.to_string());
break;
}
}
self.observer.observe_events(report_context, &events);
let event_type = frame.event_type().unwrap_or_default();
if matches!(event_type, "error" | "response.failed") {
@@ -735,14 +728,7 @@ impl ResponsesProviderAttempt {
"client_api_format": "openai:responses",
});
let report_context = self.lifecycle.report_context().unwrap_or(&fallback_context);
let mut summary = match self.observer.finish(report_context) {
Ok(Some(summary)) => summary,
Ok(None) => ExecutionStreamTerminalSummary::default(),
Err(error) => {
self.observer.disable_with_error(error.to_string());
self.observer.latest_summary().cloned().unwrap_or_default()
}
};
let mut summary = self.observer.finish(report_context);
if let Some(reason) = facts.forced_error() {
if summary.parser_error.is_none() {
summary.parser_error = Some(reason.to_string());
@@ -967,7 +953,7 @@ mod tests {
use aether_contracts::ExecutionTimeouts;
use serde_json::json;
use crate::ai_serving::api::StreamingStandardTerminalObserver;
use super::super::observation::ResponsesStructuredTerminalObserver;
use super::super::frame::ParsedResponsesWebSocketFrame;
use super::super::settlement::{
@@ -1085,14 +1071,9 @@ mod tests {
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(&report_context, capture.into_bytes())
.expect("WebSocket terminal event should be accepted by the usage observer");
let summary = observer
.finish(&report_context)
.expect("WebSocket terminal observer should finish")
.expect("WebSocket terminal observer should produce a summary");
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&report_context, &[&event]);
let summary = observer.finish(&report_context);
let usage = summary
.standardized_usage
.expect("response.completed usage must reach the terminal summary");
@@ -1141,14 +1122,9 @@ mod tests {
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(&report_context, websocket_event_as_sse_line(&event))
.expect("a legitimate incomplete must be accepted by the usage observer");
let summary = observer
.finish(&report_context)
.expect("terminal observer should finish")
.expect("terminal observer should produce a summary");
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&report_context, &[&event]);
let summary = observer.finish(&report_context);
assert!(summary.observed_finish);
assert_eq!(summary.finish_reason.as_deref(), Some("length"));
assert!(summary.parser_error.is_none());
@@ -1227,14 +1203,9 @@ mod tests {
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
});
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(&report_context, websocket_event_as_sse_line(&completed))
.expect("the terminal event must be accepted by the usage observer");
let summary = observer
.finish(&report_context)
.expect("terminal observer should finish")
.expect("terminal observer should produce a summary");
let mut observer = ResponsesStructuredTerminalObserver::default();
observer.observe_events(&report_context, &[&completed]);
let summary = observer.finish(&report_context);
assert!(summary.observed_finish);
assert!(summary.parser_error.is_none());
let usage = summary