mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
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:
@@ -1264,6 +1264,11 @@ impl OpenAIResponsesProviderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 入口:剥掉 `data:` 包装后交给 [`Self::push_event`]。
|
||||
///
|
||||
/// 解码是这个函数唯一做的事,协议状态机全在 `push_event` 里。已经持有结构化
|
||||
/// 事件的传输(Responses WebSocket)应当直接调用 `push_event`,不要为了复用
|
||||
/// 这个入口先把事件拼回 SSE 文本。
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -1272,6 +1277,17 @@ impl OpenAIResponsesProviderState {
|
||||
let Some(value) = decode_json_data_line(&line) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.push_event(report_context, &value)
|
||||
}
|
||||
|
||||
/// 结构化入口:消费一个已经解析好的 Responses 协议事件。
|
||||
///
|
||||
/// 取借用而不是所有权:持有结构化事件的传输不必为了调用它先克隆一份。
|
||||
pub fn push_event(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
value: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(response) = value.get("response").and_then(Value::as_object) {
|
||||
self.response_id = response
|
||||
@@ -1301,12 +1317,12 @@ impl OpenAIResponsesProviderState {
|
||||
}
|
||||
"response.output_text.delta" | "response.outtext.delta" => match value.get("delta") {
|
||||
Some(Value::String(piece)) if !piece.is_empty() => {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_text_delta(report_context, &mut out, key, piece);
|
||||
}
|
||||
Some(Value::Object(delta)) => {
|
||||
if let Some(text) = delta.get("text").and_then(Value::as_str) {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_missing_text(report_context, &mut out, key, text);
|
||||
}
|
||||
}
|
||||
@@ -1317,7 +1333,7 @@ impl OpenAIResponsesProviderState {
|
||||
if part.get("type").and_then(Value::as_str) == Some("output_text") {
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
if !text.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_missing_text(report_context, &mut out, key, text);
|
||||
}
|
||||
}
|
||||
@@ -1356,7 +1372,7 @@ impl OpenAIResponsesProviderState {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !text.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_missing_text(report_context, &mut out, key, text);
|
||||
}
|
||||
}
|
||||
@@ -1366,7 +1382,7 @@ impl OpenAIResponsesProviderState {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !piece.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_text_delta(report_context, &mut out, key, piece);
|
||||
}
|
||||
}
|
||||
@@ -1383,7 +1399,7 @@ impl OpenAIResponsesProviderState {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !refusal.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_missing_text(report_context, &mut out, key, refusal);
|
||||
}
|
||||
}
|
||||
@@ -1393,7 +1409,7 @@ impl OpenAIResponsesProviderState {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !piece.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_text_delta(report_context, &mut out, key, piece);
|
||||
}
|
||||
}
|
||||
@@ -1404,7 +1420,7 @@ impl OpenAIResponsesProviderState {
|
||||
.or_else(|| value.get("text").and_then(Value::as_str))
|
||||
.unwrap_or_default();
|
||||
if !transcript.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
let key = Self::text_part_key_from_event(value);
|
||||
self.emit_missing_text(report_context, &mut out, key, transcript);
|
||||
}
|
||||
}
|
||||
@@ -1477,7 +1493,7 @@ impl OpenAIResponsesProviderState {
|
||||
self.emit_output_item_event(
|
||||
report_context,
|
||||
&mut out,
|
||||
&value,
|
||||
value,
|
||||
item,
|
||||
output_index,
|
||||
false,
|
||||
@@ -1724,7 +1740,7 @@ impl OpenAIResponsesProviderState {
|
||||
self.emit_output_item_event(
|
||||
report_context,
|
||||
&mut out,
|
||||
&value,
|
||||
value,
|
||||
item,
|
||||
output_index,
|
||||
true,
|
||||
@@ -1742,7 +1758,7 @@ impl OpenAIResponsesProviderState {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some(openai_responses_incomplete_finish_reason(&value)),
|
||||
finish_reason: Some(openai_responses_incomplete_finish_reason(value)),
|
||||
usage: canonical_usage_from_openai_usage(response.get("usage")),
|
||||
},
|
||||
});
|
||||
@@ -1752,14 +1768,14 @@ impl OpenAIResponsesProviderState {
|
||||
event_type if openai_responses_stream_event_is_known_noop(event_type) => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
event_type if openai_stream_payload_is_terminal_error(&value) => {
|
||||
event_type if openai_stream_payload_is_terminal_error(value) => {
|
||||
self.finished = true;
|
||||
let mut payload = value.clone();
|
||||
if event_type != "response.failed"
|
||||
&& event_type != "response.incomplete"
|
||||
&& event_type != "error"
|
||||
{
|
||||
payload = openai_stream_terminal_error_body(&value).unwrap_or(payload);
|
||||
payload = openai_stream_terminal_error_body(value).unwrap_or(payload);
|
||||
if let Some(object) = payload.as_object_mut() {
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
|
||||
@@ -251,6 +251,44 @@ impl StreamingStandardTerminalObserver {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 结构化入口:给已经持有解析好的协议事件的传输用(Responses WebSocket),
|
||||
/// 避免为了复用 [`Self::push_line`] 把事件重新拼成 `data: {json}` 再解析回来。
|
||||
///
|
||||
/// 只要 provider 的协议状态机本身接受结构化事件,这条路径与 `push_line`
|
||||
/// 完全等价——`push_line` 现在就是「解码 + `push_event`」。
|
||||
///
|
||||
/// `openai:image` 的终态状态机没有结构化入口(它按 SSE 行做增量解析),
|
||||
/// 这里返回 `Err`,由调用方 `disable_with_error` 把摘要标成 parser_error,
|
||||
/// 而不是静默丢事件。
|
||||
pub fn push_event(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
event: &Value,
|
||||
) -> Result<(), AiSurfaceFinalizeError> {
|
||||
self.ensure_initialized(report_context);
|
||||
let Some(provider) = self.provider.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
match provider {
|
||||
TerminalStreamParser::Standard(provider) => {
|
||||
let frames = provider.push_event(report_context, event)?;
|
||||
let actual_service_tier = provider.actual_service_tier().map(ToOwned::to_owned);
|
||||
self.observe_frames(frames);
|
||||
if let Some(actual_service_tier) = actual_service_tier {
|
||||
self.latest_summary
|
||||
.get_or_insert_with(ExecutionStreamTerminalSummary::default)
|
||||
.provider_actual_service_tier = Some(actual_service_tier);
|
||||
}
|
||||
}
|
||||
TerminalStreamParser::OpenAIImage(_) => {
|
||||
return Err(AiSurfaceFinalizeError::new(
|
||||
"openai:image terminal observation has no structured event entry",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -404,6 +442,24 @@ impl ProviderStreamParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 结构化入口。目前只有 `openai:responses` 有传输会走它(Responses
|
||||
/// WebSocket);其余格式的协议状态机同样可以按「解码 + push_event」机械拆分,
|
||||
/// 等到真有非 SSE 传输需要时再拆,不做无调用方的接口。
|
||||
fn push_event(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
event: &Value,
|
||||
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
||||
match self {
|
||||
ProviderStreamParser::OpenAIResponses(state) => state.push_event(report_context, event),
|
||||
ProviderStreamParser::OpenAIChat(_)
|
||||
| ProviderStreamParser::Claude(_)
|
||||
| ProviderStreamParser::Gemini(_) => Err(AiSurfaceFinalizeError::new(
|
||||
"this provider stream parser has no structured event entry",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -2490,3 +2546,257 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod structured_entry_tests {
|
||||
use super::StreamingStandardTerminalObserver;
|
||||
use aether_contracts::ExecutionStreamTerminalSummary;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn report_context() -> Value {
|
||||
json!({
|
||||
"provider_api_format": "openai:responses",
|
||||
"client_api_format": "openai:responses",
|
||||
"mapped_model": "gpt-5-codex",
|
||||
})
|
||||
}
|
||||
|
||||
/// 用 SSE 入口观测一组事件。这是 C5 之前 WebSocket 走的路径:把结构化事件
|
||||
/// 拼成 `data: {json}` 再交给解析器。
|
||||
fn summary_via_push_line(events: &[Value]) -> ExecutionStreamTerminalSummary {
|
||||
let context = report_context();
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
for event in events {
|
||||
observer
|
||||
.push_line(&context, format!("data: {event}\n\n").into_bytes())
|
||||
.expect("the SSE entry must accept these events");
|
||||
}
|
||||
observer
|
||||
.finish(&context)
|
||||
.expect("the observer must finish")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 用结构化入口观测同一组事件。这是 C5 之后的路径。
|
||||
fn summary_via_push_event(events: &[Value]) -> ExecutionStreamTerminalSummary {
|
||||
let context = report_context();
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
for event in events {
|
||||
observer
|
||||
.push_event(&context, event)
|
||||
.expect("the structured entry must accept these events");
|
||||
}
|
||||
observer
|
||||
.finish(&context)
|
||||
.expect("the observer must finish")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn assert_entries_agree(label: &str, events: &[Value]) {
|
||||
let via_line = summary_via_push_line(events);
|
||||
let via_event = summary_via_push_event(events);
|
||||
assert_eq!(
|
||||
via_line, via_event,
|
||||
"the SSE entry and the structured entry must produce identical summaries for {label}"
|
||||
);
|
||||
}
|
||||
|
||||
fn created() -> Value {
|
||||
json!({"type": "response.created", "response": {"id": "resp_diff", "model": "gpt-5-codex"}})
|
||||
}
|
||||
|
||||
fn text_delta(piece: &str) -> Value {
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_diff",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": piece,
|
||||
})
|
||||
}
|
||||
|
||||
/// 批量事件:WS 一帧可以带多个协议事件,逐个喂入的结果必须和逐行喂入一致。
|
||||
#[test]
|
||||
fn a_batched_delta_sequence_agrees_across_both_entries() {
|
||||
let events = vec![
|
||||
created(),
|
||||
text_delta("he"),
|
||||
text_delta("ll"),
|
||||
text_delta("o"),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_diff",
|
||||
"model": "gpt-5-codex",
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 11, "output_tokens": 3, "total_tokens": 14},
|
||||
},
|
||||
}),
|
||||
];
|
||||
assert_entries_agree("a batched delta sequence", &events);
|
||||
let summary = summary_via_push_event(&events);
|
||||
assert!(summary.observed_finish);
|
||||
assert_eq!(summary.response_id.as_deref(), Some("resp_diff"));
|
||||
let usage = summary
|
||||
.standardized_usage
|
||||
.as_ref()
|
||||
.expect("completed carries usage");
|
||||
assert_eq!(usage.input_tokens, 11);
|
||||
assert_eq!(usage.output_tokens, 3);
|
||||
}
|
||||
|
||||
/// 合法 `response.incomplete`:C1 定过的语义(终态、可计费),两条入口必须
|
||||
/// 得到同一个摘要,尤其是 finish_reason 与 parser_error 的取值。
|
||||
#[test]
|
||||
fn a_legitimate_incomplete_agrees_across_both_entries() {
|
||||
let events = vec![
|
||||
created(),
|
||||
text_delta("partial"),
|
||||
json!({
|
||||
"type": "response.incomplete",
|
||||
"response": {
|
||||
"id": "resp_diff",
|
||||
"model": "gpt-5-codex",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {"reason": "max_output_tokens"},
|
||||
"usage": {"input_tokens": 7, "output_tokens": 5, "total_tokens": 12},
|
||||
},
|
||||
}),
|
||||
];
|
||||
assert_entries_agree("a legitimate incomplete", &events);
|
||||
let summary = summary_via_push_event(&events);
|
||||
assert!(summary.observed_finish);
|
||||
assert!(
|
||||
summary.parser_error.is_none(),
|
||||
"a legitimate incomplete is not a parser error: {:?}",
|
||||
summary.parser_error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_error_agrees_across_both_entries() {
|
||||
let events = vec![
|
||||
created(),
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": {"type": "server_error", "message": "upstream exploded"},
|
||||
}),
|
||||
];
|
||||
assert_entries_agree("a terminal error", &events);
|
||||
let summary = summary_via_push_event(&events);
|
||||
assert!(summary.observed_finish);
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("error"));
|
||||
assert!(summary.parser_error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_failed_event_agrees_across_both_entries() {
|
||||
assert_entries_agree(
|
||||
"a response.failed event",
|
||||
&[
|
||||
created(),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_diff",
|
||||
"model": "gpt-5-codex",
|
||||
"status": "failed",
|
||||
"error": {"type": "server_error", "message": "generation failed"},
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 未知事件只增计数、不改终态判定,两条入口的计数必须一致。
|
||||
#[test]
|
||||
fn unknown_events_agree_across_both_entries() {
|
||||
let events = vec![
|
||||
created(),
|
||||
json!({"type": "response.some_future_event", "payload": {"anything": true}}),
|
||||
json!({"type": "response.another_future_event"}),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_diff",
|
||||
"model": "gpt-5-codex",
|
||||
"status": "completed",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
}),
|
||||
];
|
||||
assert_entries_agree("unknown events", &events);
|
||||
let summary = summary_via_push_event(&events);
|
||||
assert!(summary.observed_finish);
|
||||
assert!(summary.unknown_event_count > 0, "unknown events are counted");
|
||||
}
|
||||
|
||||
/// 供应商声明的 service tier 通过两条入口都要落到摘要上。
|
||||
#[test]
|
||||
fn a_service_tier_agrees_across_both_entries() {
|
||||
assert_entries_agree(
|
||||
"a declared service tier",
|
||||
&[
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_diff",
|
||||
"model": "gpt-5-codex",
|
||||
"service_tier": "priority",
|
||||
},
|
||||
}),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_diff",
|
||||
"model": "gpt-5-codex",
|
||||
"status": "completed",
|
||||
"service_tier": "priority",
|
||||
"usage": {"input_tokens": 2, "output_tokens": 2, "total_tokens": 4},
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 没有任何供应商终态事件。注意 `finish()` 会补一个 `stop`(这是 HTTP 与
|
||||
/// WebSocket 共享的既有行为,C5 不改),所以 `observed_finish` 为真而 usage
|
||||
/// 缺失——真正的「缺终态」判定看的是 usage 与被捕获的 body。这里要钉住的是
|
||||
/// 两条入口在这种不完整序列上仍然给出同一个摘要。
|
||||
#[test]
|
||||
fn a_missing_terminal_agrees_across_both_entries() {
|
||||
let events = vec![created(), text_delta("truncated")];
|
||||
assert_entries_agree("a missing terminal", &events);
|
||||
let summary = summary_via_push_event(&events);
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("stop"));
|
||||
assert!(
|
||||
summary.standardized_usage.is_none(),
|
||||
"a synthesized finish carries no usage"
|
||||
);
|
||||
}
|
||||
|
||||
/// `openai:image` 没有结构化入口:必须显式报错,让调用方标记 parser_error,
|
||||
/// 而不是静默丢掉事件、把摘要留成「未观察到终态」。
|
||||
#[test]
|
||||
fn the_image_format_rejects_the_structured_entry() {
|
||||
let context = json!({
|
||||
"provider_api_format": "openai:image",
|
||||
"client_api_format": "openai:image",
|
||||
"mapped_model": "gpt-image-1",
|
||||
});
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
let error = observer
|
||||
.push_event(&context, &json!({"type": "image_generation.completed"}))
|
||||
.expect_err("openai:image has no structured entry");
|
||||
assert!(
|
||||
error.to_string().contains("structured event entry"),
|
||||
"the error must name the missing entry: {error}"
|
||||
);
|
||||
|
||||
observer.disable_with_error(error.to_string());
|
||||
let summary = observer
|
||||
.latest_summary()
|
||||
.expect("disable_with_error records a summary");
|
||||
assert!(summary.parser_error.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user