mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
fix(usage): treat empty body_state as missing terminal event
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user