mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
fix(ws): bill a provider-reached terminal even when client delivery fails
评审第 5 条后半:provider 终态已经到达、只是 gateway 写客户端 socket 失败时,
relay loop 用 client_disconnected() 覆盖了结算信号,于是一条供应商已经完成推理
并消耗了 token 的响应被记成 void billing、candidate 记 Cancelled、不投射供应商
效果、也不提交 execution report。上游成本凭空消失。
结算表只改一行:作废账单的条件从
provider.cancelled_by_provider() || delivery.is_aborted()
收紧为
provider.cancelled_by_provider() || (delivery.is_aborted() && !provider.is_terminal())
于是 Terminal{cancelled=false} + delivery Aborted 与 delivery Complete 落在同一侧:
Billed、candidate Success 或 Failed、投射供应商效果、提交 execution report。
状态码随之变成纯 provider 事实(不再把 200 改写成 499);作废分支的 provider
状态码本身就是 499,取值不变。
依据:供应商已经完成推理并消耗 token,客户端还能用 previous_response_id 续取
这条响应。供应商没给出终态时(客户端先走了)仍然作废,这一侧未改。
配套改动:
- connection.rs 写客户端失败处改为 record_client_delivery_aborted(reason) +
settle_signal_for_client_delivery_failure(terminal_outcome):provider 终态已到达
就用那条终态作结算信号,不再无条件覆盖。投递失败原因也不再谎称
「客户端在终态前断开」。
- 投递结果记在 attempt 上而非 logical turn 上:结算按 attempt 进行,且配额透明
重试时各 attempt 的投递结果彼此独立。
- report_context 新增 websocket_client_delivery="aborted" 与
websocket_client_delivery_reason,只增字段不改既有字段,便于事后区分
「客户端拿到了」和「客户端没拿到但已计费」。
- candidate error_type 新增 client_delivery_failed(原先这个场景写的是
websocket_cancelled)。它排在供应商侧分类之前:这条记录之所以特别正是因为
内容没送到客户端,供应商侧判定仍由 candidate_status 与 error_message 保留。
- finish_summary 改用作废判定而非「投递失败」判定:provider 终态已到达时摘要
必须保留真实的 finish_reason 与 usage,否则计费记录会被写坏。
e2e 期望值变化:client_disconnect_mid_turn_still_settles_the_usage_row 改名为
client_disconnect_before_any_provider_output_settles_a_void_row,并补上
「不计费 + status=cancelled + status_code=499」的断言。原用例的 mock 行为是
StallAfterCreated(只发 response.created 就静默),provider 从未给出终态,所以
它走的是未改动的作废一侧;原来的文档注释说「must still be billed」与实际语义
不符,一并纠正。真正被修正的那一行无法在 e2e 里确定性触发——它取决于 relay
loop 的 select! 先观察到上游终态帧还是先观察到已关闭的客户端 socket,是构造性
竞态——因此由 relay 级单测确定性覆盖,e2e 里以注释指向这两个单测。
新增 7 个测试:结算表修正行(并与「投递成功」逐字段对照,只有 candidate 错误
分类不同)、无终态时仍作废、供应商声明取消即使送达也不计费、结算信号选择、
已记录的投递失败不被结算信号覆盖、relay 级「终态到达 + 客户端已关闭 ⇒ Billed /
Success / ProviderSuccess / 已提交 report 且 usage 完整保留」及其镜像、
report_context 只增不改。
This commit is contained in:
@@ -23,6 +23,7 @@ use super::quota::{
|
||||
use super::relay_policy::{
|
||||
classify_quota_relay, fatal_relay_policy, FatalRelaySignal, QuotaRelayAction, QuotaRelayFacts,
|
||||
};
|
||||
use super::settlement::settle_signal_for_client_delivery_failure;
|
||||
use super::state::BoundResponsesConnection;
|
||||
use super::turn::{
|
||||
ResponsesProviderAttempt, ResponsesWebSocketTurnObservation, ResponsesWebSocketTurnOutcome,
|
||||
@@ -41,6 +42,11 @@ use crate::AppState;
|
||||
|
||||
const LOG_TARGET: &str = "aether_gateway::handlers::proxy::responses_ws";
|
||||
|
||||
/// 写客户端 socket 失败时记录的投递失败原因。刻意不说「客户端在终态前断开」:
|
||||
/// 供应商的终态可能已经到达,只是最后一跳没送出去。
|
||||
const CLIENT_DELIVERY_FAILED_REASON: &str =
|
||||
"gateway could not relay the provider event to the client";
|
||||
|
||||
macro_rules! debug {
|
||||
($($arg:tt)*) => {
|
||||
tracing::debug!(target: LOG_TARGET, $($arg)*)
|
||||
@@ -484,12 +490,18 @@ pub(super) async fn relay_bound_connection(
|
||||
websocket = true,
|
||||
trace_id = %context.trace_id,
|
||||
error_code = error.as_str(),
|
||||
provider_terminal_reached = terminal_outcome.is_some(),
|
||||
"gateway could not relay a provider event to the client"
|
||||
);
|
||||
// 投递失败是独立事实,不能覆盖已经到达的 provider 终态:
|
||||
// 供应商已经完成推理并消耗 token,账单按它的终态计。
|
||||
bound
|
||||
.turn_state
|
||||
.record_client_delivery_aborted(CLIENT_DELIVERY_FAILED_REASON);
|
||||
finalize_active_turn(
|
||||
bound,
|
||||
state,
|
||||
ResponsesWebSocketTurnOutcome::client_disconnected(),
|
||||
settle_signal_for_client_delivery_failure(terminal_outcome),
|
||||
).await;
|
||||
close_bound_upstream(bound).await;
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user