ZheFox
c8118edf36
fix(ws): harden Responses connection lifecycle
...
Revalidate control policy per turn, isolate downstream credentials, and make planner/turn ownership cancellation-safe.
Preserve opaque protocol events, align configurable timeout semantics, and extend end-to-end security and settlement coverage.
2026-08-17 18:50:29 +08:00
AAEE86
1d3051cb89
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。
2026-08-17 14:53:33 +08:00
AAEE86
dc3743aecf
refactor(ws): 拆分 LogicalTurn 与 ProviderAttempt,结算改表驱动
...
评审第 5 条:一个 ResponsesWebSocketTurn 同时代表 logical turn 和 provider
attempt,finalize() 又用 outcome.cancelled() 一个布尔驱动 billing、candidate
状态和供应商效果,于是 provider 终态已经到达、只是最后一跳写客户端失败时,
供应商事实会被 Cancelled 覆盖掉。
- ResponsesWebSocketTurn → ResponsesProviderAttempt,
ActiveResponsesWebSocketTurn → ActiveProviderAttempt:类型名字明确它只代表
一次上游执行,logical turn 由 C1 落地的 LogicalTurn 承担。
- 新增 settlement.rs:AttemptProviderOutcome × AttemptClientDelivery 两个正交
事实,classify_attempt_settlement 一张表推出 status_code / billing /
candidate 状态 / candidate 错误分类 / 供应商效果 / 是否提交 execution report。
- attempt 观察到 provider 终态即记录 provider_outcome。结算信号
ResponsesWebSocketTurnOutcome 只回答「为什么现在结算」:ProviderTerminal 与
Failure 对 provider 是权威的,Cancelled 只描述客户端/连接层面的停止,不再
覆盖已观察到的 provider 事实。
- candidate 状态与 candidate 错误分类分开输出:现状存在
「missing_terminal=true 而记账层判 Success」的组合(report kind 不要求观察到
终态事件时),会写出 status=Success + error_type=stream_missing_terminal_event,
这个组合必须原样保留。
classify_responses_websocket_turn_effect 的判定表原样搬入 settlement.rs,分支
和顺序均未改动,两个既有不变量测试随之迁移。
行为等价。结算表当前口径与拆分前完全一致:客户端投递失败仍与「供应商声明取消」
落在同一侧(作废账单、candidate 记 Cancelled、只释放 lease、不提交 execution
report),即使 provider 终态已经到达——这一行由
settlement_table_row_client_delivery_failure_currently_voids_a_reached_terminal
锁住现状,修正它是下一步独立的行为修正。
新增 15 个测试:outcome → 双事实映射表逐行(含 stream_timeout 只在 504 失败一族
成立、provider 终态即使 504 也不投射流式超时)、结算表逐行、投递失败时
forced_error 必须为 None、已观察终态不被 Cancelled 覆盖、以及跨整张表的
「每个分支都释放 pool key lease」「作废账单一律不提交 report」不变量。
2026-08-17 14:53:05 +08:00
AAEE86
1c5ee5228c
refactor(ws): 用 ResponsesTurnState 收敛连接 turn 状态
...
评审第 2 条:BoundResponsesConnection 用 response_in_flight、active_turn、
active_response_create 三个可独立变化的字段编码同一件事,8 种组合里只有 3 种
合法,非法组合只能靠调用点的 if 和「记得同时改另外两个字段」来避免。
三字段合并为一个 ResponsesTurnState:
Idle 没有进行中的 logical turn
Responding { logical, attempt } logical 与 attempt 必须同时存在
Replanning { logical } attempt 已取走去结算/重绑,logical 仍在
Replanning 不是新概念:配额透明重试期间现状就处于这个状态,只是靠
Option::take 意外得到。转换只能走 begin / detach_attempt / resume / end,
response_in_flight 与「是否接受新 response.create」都由变体推导。
由此消除的运行时不变量(原来全靠调用点自觉):
- 有 attempt 必有 logical turn
- response_in_flight 与 attempt 同生共死(原来 client 写失败后
active_turn=None 而 response_in_flight 仍为 true)
- logical turn 结束时必须清 attempt:原来 `active_response_create = None`
在 connection.rs 里手写 13 处,漏一处就残留;现在只有 end() 一个出口
- 上游绑定返回的连接不再自带 response_in_flight=true 的半成品状态
同时删除 update_response_in_flight:Started 帧把已经是 true 的字段再设一次,
Close 帧因为没有解析出的 frame 而根本不触发,是纯冗余写;它在 Idle 态收到
Started 帧时还会把 response_in_flight 置真,从而永久阻塞后续 response.create。
行为等价。ActiveResponsesWebSocketRequest 改名 LogicalTurn 并随状态机移入
新的 turn_state.rs;状态机对 attempt 类型泛型化,测试用轻量替身驱动同一套
转换逻辑,无需 AppState 或真实 socket。
2026-08-17 14:52:57 +08:00
AAEE86
9d80281b53
fix(ws): route WS planning and continuation through PII redaction
2026-08-17 14:52:49 +08:00
AAEE86
71b54070e8
feat(gateway): Codex/OpenAI Responses WebSocket 代理模式
...
在 /v1/responses 上支持 WebSocket 升级,把客户端帧中继到上游 Codex /
OpenAI Responses WebSocket 端点,同时保持既有的路由、鉴权、配额与用量
语义:
- 路由与准入:control/route/ai.rs 识别 WebSocket 升级请求;
websocket/ingress.rs 复用 API Key 鉴权、IP 规则与并发许可,并引入
独立的 WebSocket 连接许可
- 中继:websocket/responses/* 按 connection / session / turn 分层,
帧解析归一化、socket 写入有界、continuation 保持调度亲和性
- 配额:orchestration/codex_quota_breaker.rs 在账号配额耗尽时熔断并
自动恢复,不再直接断开客户端连接
- 用量:每个 turn 的终态用量落库,request_metadata 记录
websocket_mode / websocket_transport,管理端与 usage 视图暴露
is_websocket
- 管理端:provider 可配置 Responses WebSocket 开关
2026-08-17 14:50:33 +08:00