fix(usage): treat empty body_state as missing terminal event

This commit is contained in:
zhefox
2026-05-25 13:38:18 +08:00
parent c92bdfba16
commit ec681335e8
4 changed files with 208 additions and 10 deletions
+40 -4
View File
@@ -407,7 +407,10 @@ fn is_openai_responses_family_format_alias(value: &str) -> bool {
fn stream_report_captured_terminal_state(
payload: &GatewayStreamReportRequest,
) -> Option<StreamCapturedTerminalState> {
let provider_state = stream_report_provider_capture_requires_terminal_event(payload)
let provider_requires_terminal =
stream_report_provider_capture_requires_terminal_event(payload);
let client_requires_terminal = stream_report_client_capture_requires_terminal_event(payload);
let provider_state = provider_requires_terminal
.then(|| {
stream_capture_terminal_state_from_base64(
payload.provider_body_base64.as_deref(),
@@ -415,7 +418,7 @@ fn stream_report_captured_terminal_state(
)
})
.flatten();
let client_state = stream_report_client_capture_requires_terminal_event(payload)
let client_state = client_requires_terminal
.then(|| {
stream_capture_terminal_state_from_base64(
payload.client_body_base64.as_deref(),
@@ -423,7 +426,37 @@ fn stream_report_captured_terminal_state(
)
})
.flatten();
combine_stream_terminal_states(provider_state, client_state)
combine_stream_terminal_states(provider_state, client_state).or_else(|| {
stream_report_required_captures_are_empty(
payload,
provider_requires_terminal,
client_requires_terminal,
)
.then_some(StreamCapturedTerminalState::Missing)
})
}
fn stream_report_required_captures_are_empty(
payload: &GatewayStreamReportRequest,
provider_requires_terminal: bool,
client_requires_terminal: bool,
) -> bool {
let mut has_required_capture = false;
let mut all_required_captures_empty = true;
if provider_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= payload.provider_body_base64.is_none()
&& payload.provider_body_state == Some(UsageBodyCaptureState::None);
}
if client_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= payload.client_body_base64.is_none()
&& payload.client_body_state == Some(UsageBodyCaptureState::None);
}
has_required_capture && all_required_captures_empty
}
fn stream_report_provider_capture_requires_terminal_event(
@@ -524,7 +557,10 @@ fn filter_incomplete_capture_terminal_state(
fn stream_body_capture_can_prove_missing_terminal(
body_state: Option<UsageBodyCaptureState>,
) -> bool {
matches!(body_state, None | Some(UsageBodyCaptureState::Inline))
matches!(
body_state,
None | Some(UsageBodyCaptureState::Inline) | Some(UsageBodyCaptureState::None)
)
}
pub fn stream_capture_terminal_state(value: &Value) -> Option<StreamCapturedTerminalState> {
+134 -4
View File
@@ -943,9 +943,20 @@ pub fn build_stream_terminal_usage_seed(
context_seed.client_contract.as_str(),
context_seed.provider_contract.as_str(),
);
let observed_stream_finish = observed_stream_finish.or_else(|| {
captured_terminal_state.map(|state| state != StreamCapturedTerminalState::Missing)
});
let empty_required_capture_missing_terminal = stream_empty_required_captures_missing_terminal(
report_kind.as_str(),
context_seed.client_contract.as_str(),
context_seed.provider_contract.as_str(),
provider_response_full.as_ref(),
provider_response_body_state,
client_response.as_ref(),
client_response_body_state,
);
let observed_stream_finish = observed_stream_finish
.or_else(|| {
captured_terminal_state.map(|state| state != StreamCapturedTerminalState::Missing)
})
.or_else(|| empty_required_capture_missing_terminal.then_some(false));
let missing_observed_finish = matches!(observed_stream_finish, Some(false))
&& (requires_observed_terminal_event
|| !standardized_usage
@@ -1137,7 +1148,10 @@ fn captured_stream_terminal_state_from_body(
fn stream_body_capture_can_prove_missing_terminal(
body_state: Option<UsageBodyCaptureState>,
) -> bool {
matches!(body_state, None | Some(UsageBodyCaptureState::Inline))
matches!(
body_state,
None | Some(UsageBodyCaptureState::Inline) | Some(UsageBodyCaptureState::None)
)
}
fn combine_stream_capture_terminal_states(
@@ -1161,6 +1175,48 @@ fn combine_stream_capture_terminal_states(
}
}
#[allow(clippy::too_many_arguments)]
fn stream_empty_required_captures_missing_terminal(
report_kind: &str,
client_contract: &str,
provider_contract: &str,
provider_response: Option<&Value>,
provider_response_body_state: Option<UsageBodyCaptureState>,
client_response: Option<&Value>,
client_response_body_state: Option<UsageBodyCaptureState>,
) -> bool {
let report_kind_requires_terminal_event =
stream_report_kind_requires_observed_terminal_event(report_kind);
let provider_contract_requires_terminal_event =
is_openai_responses_family_format_alias(provider_contract);
let client_contract_requires_terminal_event =
is_openai_responses_family_format_alias(client_contract);
let fallback_requires_terminal_event = report_kind_requires_terminal_event
&& !provider_contract_requires_terminal_event
&& !client_contract_requires_terminal_event;
let provider_requires_terminal =
provider_contract_requires_terminal_event || fallback_requires_terminal_event;
let client_requires_terminal =
client_contract_requires_terminal_event || fallback_requires_terminal_event;
let mut has_required_capture = false;
let mut all_required_captures_empty = true;
if provider_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= provider_response.is_none()
&& provider_response_body_state == Some(UsageBodyCaptureState::None);
}
if client_requires_terminal {
has_required_capture = true;
all_required_captures_empty &= client_response.is_none()
&& client_response_body_state == Some(UsageBodyCaptureState::None);
}
has_required_capture && all_required_captures_empty
}
fn stream_report_kind_requires_observed_terminal_event(report_kind: &str) -> bool {
let report_kind = report_kind.trim().to_ascii_lowercase();
report_kind.starts_with("openai_responses_")
@@ -4073,6 +4129,80 @@ mod tests {
assert_eq!(event.data.output_tokens, None);
}
#[test]
fn stream_terminal_usage_marks_empty_openai_responses_capture_as_missing_terminal() {
let plan = ExecutionPlan {
request_id: "req-stream-empty-capture-1".to_string(),
candidate_id: Some("cand-stream-empty-capture-1".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://example.com/v1/responses".to_string(),
headers: BTreeMap::new(),
content_type: None,
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: true,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.5".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let payload = GatewayStreamReportRequest {
trace_id: "trace-stream-empty-capture-1".to_string(),
report_kind: "openai_responses_stream_success".to_string(),
report_context: Some(json!({
"client_api_format": "openai:responses",
"provider_api_format": "openai:responses"
})),
status_code: 200,
headers: BTreeMap::new(),
provider_body_base64: None,
provider_body_state: Some(UsageBodyCaptureState::None),
client_body_base64: None,
client_body_state: Some(UsageBodyCaptureState::None),
terminal_summary: None,
telemetry: None,
};
let event =
build_stream_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("usage event should build");
assert_eq!(event.event_type, UsageEventType::Failed);
assert_eq!(event.data.status_code, Some(200));
assert_eq!(
event.data.error_category.as_deref(),
Some("stream_missing_terminal_event")
);
assert_eq!(
event.data.error_message.as_deref(),
Some("execution runtime stream ended before provider terminal event")
);
assert_eq!(
event
.data
.client_response_body
.as_ref()
.and_then(|body| body.get("error"))
.and_then(|error| error.get("type"))
.and_then(Value::as_str),
Some("stream_missing_terminal_event")
);
assert_eq!(
event.data.client_response_body_state,
Some(UsageBodyCaptureState::Inline)
);
}
#[test]
fn stream_terminal_usage_marks_missing_captured_openai_responses_terminal_as_failed() {
let plan = ExecutionPlan {
@@ -1220,13 +1220,16 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
const body = raw.body
const bodyRef = readStringField(raw, 'body_ref') ?? readStringField(raw, 'bodyRef')
const bodyState = readStringField(raw, 'body_state') ?? readStringField(raw, 'bodyState')
const meaningfulBodyState = bodyState && bodyState.toLowerCase() !== 'none'
? bodyState
: ''
if (
statusCode == null &&
!hasRenderableValue(headers) &&
!hasRenderableValue(body) &&
!bodyRef &&
!bodyState
!meaningfulBodyState
) {
return null
}
@@ -1236,7 +1239,7 @@ const normalizeUpstreamResponseDisplay = (value: unknown): Record<string, unknow
if (hasRenderableValue(headers)) data.headers = headers
if (hasRenderableValue(body)) data.body = body
if (bodyRef) data.body_ref = bodyRef
if (bodyState) data.body_state = bodyState
if (meaningfulBodyState) data.body_state = meaningfulBodyState
return data
}
@@ -481,4 +481,33 @@ describe('HorizontalRequestTimeline', () => {
expect(root.textContent).not.toContain('不再重试')
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
})
it('keeps the failure message when upstream response only records an empty body state', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-empty-body-state',
provider_id: 'provider-empty-body-state',
provider_name: 'Provider Empty Body State',
key_id: 'key-empty-body-state',
key_name: 'Empty Body State Key',
candidate_index: 0,
status: 'failed',
error_type: 'stream_missing_terminal_event',
error_message: 'execution runtime stream ended before provider terminal event',
extra_data: {
upstream_response: {
body_state: 'none',
},
},
}),
])
const root = mountTimeline(trace)
await nextTick()
expect(root.textContent).toContain('错误信息')
expect(root.textContent).toContain('execution runtime stream ended before provider terminal event')
expect(root.querySelector('.error-block .error-json')).toBeNull()
expect(root.textContent).not.toContain('"body_state":"none"')
})
})