diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs index 7c67ad1d4..763438bd3 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs @@ -58,6 +58,7 @@ async fn admin_monitoring_trace_request_returns_local_payload() { .expect("route should be handled locally"); assert_eq!(response.status(), http::StatusCode::OK); + assert!(response.headers().contains_key("x-aether-build-version")); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body should read"); @@ -93,6 +94,15 @@ async fn admin_monitoring_trace_request_resolves_usage_id_to_header_trace_id() { Some(33), Some(200), ), + sample_candidate( + "cand-other-attempt", + "trace-1", + 1, + RequestCandidateStatus::Failed, + Some(100), + Some(20), + Some(502), + ), ])); let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed( vec![sample_provider()], @@ -110,6 +120,8 @@ async fn admin_monitoring_trace_request_resolves_usage_id_to_header_trace_id() { 100, ); usage.id = "usage-row-1".to_string(); + usage.request_body_state = Some(UsageBodyCaptureState::Reference); + usage.response_body_state = Some(UsageBodyCaptureState::Reference); usage.candidate_id = Some("cand-used".to_string()); usage.request_headers = Some(json!({ "x-trace-id": "trace-1" @@ -140,6 +152,17 @@ async fn admin_monitoring_trace_request_resolves_usage_id_to_header_trace_id() { .expect("body should read"); let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse"); assert_eq!(payload["request_id"], json!("trace-1")); + assert_eq!(payload["diagnostic_request"]["usage_id"], "usage-row-1"); + assert_eq!( + payload["candidates"][0]["extra_data"]["diagnostic_context"]["usage_id"], + "usage-row-1" + ); + assert_eq!( + payload["candidates"][0]["extra_data"]["diagnostic_context"]["body_states"] + ["response_body"], + "reference" + ); + assert!(payload["candidates"][1]["extra_data"]["diagnostic_context"].is_null()); assert_eq!(payload["candidates"][0]["id"], json!("cand-used")); assert_eq!( payload["candidates"][0]["extra_data"]["first_byte_time_ms"], diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs index 28abec2c0..f35e49826 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs @@ -67,13 +67,19 @@ pub(super) async fn build_admin_monitoring_trace_request_response( let key_accounts = build_admin_monitoring_key_account_display_map(admin_state, &resolved.trace).await?; - Ok( - build_admin_monitoring_trace_request_payload_response_with_key_accounts( - &resolved.trace, - resolved.usage.as_ref(), - &key_accounts, - ), - ) + let mut response = build_admin_monitoring_trace_request_payload_response_with_key_accounts( + &resolved.trace, + resolved.usage.as_ref(), + &key_accounts, + ); + if let Ok(version) = axum::http::HeaderValue::from_str( + option_env!("AETHER_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), + ) { + response + .headers_mut() + .insert("x-aether-build-version", version); + } + Ok(response) } async fn resolve_admin_monitoring_trace( diff --git a/crates/aether-admin/src/observability/monitoring.rs b/crates/aether-admin/src/observability/monitoring.rs index f8c0337ac..6e614f146 100644 --- a/crates/aether-admin/src/observability/monitoring.rs +++ b/crates/aether-admin/src/observability/monitoring.rs @@ -312,6 +312,10 @@ pub fn build_admin_monitoring_trace_request_payload_response_with_key_accounts( "total_candidates": trace.total_candidates, "final_status": trace.final_status, "total_latency_ms": trace.total_latency_ms, + "diagnostic_request": usage.filter(|usage| admin_monitoring_usage_matches_trace(usage, &trace.request_id)).map(|usage| json!({ + "usage_id": usage.id, + "body_state": usage.request_body_state.map(|state| state.as_str()), + })), "candidates": candidates, })) .into_response() @@ -337,6 +341,7 @@ pub fn build_admin_monitoring_trace_request_candidate_payload_with_key_accounts( item.sanitize_for_admin(); let candidate = &item.candidate; let sanitized_extra_data = build_admin_monitoring_trace_candidate_extra_data( + &candidate.id, candidate.extra_data.as_ref(), candidate.status_code, usage, @@ -518,6 +523,7 @@ fn build_admin_monitoring_trace_candidate_ranking(existing: Option<&Value>) -> V } fn build_admin_monitoring_trace_candidate_extra_data( + candidate_id: &str, existing: Option<&Value>, candidate_status_code: Option, usage: Option<&StoredRequestUsageAudit>, @@ -527,6 +533,19 @@ fn build_admin_monitoring_trace_candidate_extra_data( if let Some(usage) = usage { let extra_object = extra_data.get_or_insert_with(serde_json::Map::new); + if usage.routing_candidate_id() == Some(candidate_id) { + extra_object.insert("diagnostic_context".to_string(), json!({ + "usage_id": usage.id, + "model": usage.model, + "target_model": usage.target_model, + "body_states": { + "request_body": usage.request_body_state.map(|state| state.as_str()), + "provider_request_body": usage.provider_request_body_state.map(|state| state.as_str()), + "response_body": usage.response_body_state.map(|state| state.as_str()), + "client_response_body": usage.client_response_body_state.map(|state| state.as_str()), + } + })); + } if let Some(first_byte_time_ms) = usage.first_byte_time_ms { extra_object .entry("first_byte_time_ms".to_string()) @@ -577,8 +596,21 @@ fn build_admin_monitoring_trace_candidate_extra_data( } } - sanitize_request_candidate_extra_data_for_persistence(extra_data.map(Value::Object)) - .unwrap_or(Value::Null) + let diagnostic_context = extra_data + .as_mut() + .and_then(|extra| extra.remove("diagnostic_context")); + let mut sanitized = + sanitize_request_candidate_extra_data_for_persistence(extra_data.map(Value::Object)) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + if let Some(context) = diagnostic_context { + sanitized.insert("diagnostic_context".to_string(), context); + } + if sanitized.is_empty() { + Value::Null + } else { + Value::Object(sanitized) + } } fn admin_monitoring_trace_response_data( diff --git a/crates/aether-ai/formats/src/formats/context.rs b/crates/aether-ai/formats/src/formats/context.rs index 6c09e9114..02b189156 100644 --- a/crates/aether-ai/formats/src/formats/context.rs +++ b/crates/aether-ai/formats/src/formats/context.rs @@ -238,3 +238,124 @@ impl fmt::Display for FormatError { } impl Error for FormatError {} + +impl FormatError { + pub fn diagnostic(&self) -> Value { + let (code, operation, field, reason) = match self { + Self::UnsupportedFormat(_) => ("unsupported_format", "select_format", None, None), + Self::RequestParseFailed { .. } => { + ("request_parse_failed", "parse_request", None, None) + } + Self::RequestEmitFailed { .. } => ("request_emit_failed", "emit_request", None, None), + Self::ResponseParseFailed { .. } => { + ("response_parse_failed", "parse_response", None, None) + } + Self::ResponseEmitFailed { .. } => { + ("response_emit_failed", "emit_response", None, None) + } + Self::UnsupportedField { field, reason, .. } => { + ("unsupported_field", "validate", Some(field), Some(reason)) + } + Self::UnauditedField { field, reason, .. } => { + ("unaudited_field", "validate", Some(field), Some(reason)) + } + Self::InvalidEnumValue { field, .. } => { + ("invalid_enum_value", "validate", Some(field), None) + } + Self::LossyConversionBlocked { field, reason, .. } => ( + "lossy_conversion_blocked", + "convert", + Some(field), + Some(reason), + ), + Self::InvalidTargetField { field, reason, .. } => ( + "invalid_target_field", + "validate_target", + Some(field), + Some(reason), + ), + }; + let path = field.map(|field| { + let path = if field.starts_with('$') { + field.clone() + } else { + format!("$.{field}") + }; + path.replace("[]", "[*]") + }); + let mut diagnostic = json!({ + "code": code, + "operation": operation, + "path": path.as_deref().unwrap_or("$"), + "path_source": if path.is_some() { "structured" } else { "unavailable" }, + "reason": reason, + "expected": reason, + "actual": null, + "missing_context": if path.is_some() { vec![] } else { vec!["field_path", "underlying_cause"] } + }); + if let Self::InvalidEnumValue { value, .. } = self { + diagnostic["actual"] = json!(value); + } + match self { + Self::UnsupportedFormat(format) + | Self::RequestParseFailed { format } + | Self::RequestEmitFailed { format } + | Self::ResponseParseFailed { format } + | Self::ResponseEmitFailed { format } + | Self::UnsupportedField { format, .. } + | Self::InvalidEnumValue { format, .. } + | Self::InvalidTargetField { format, .. } => diagnostic["format"] = json!(format), + _ => {} + } + if let Self::UnauditedField { + source_format, + target_format, + .. + } + | Self::LossyConversionBlocked { + source_format, + target_format, + .. + } = self + { + diagnostic["source_format"] = json!(source_format); + diagnostic["target_format"] = json!(target_format); + } + diagnostic + } +} + +#[cfg(test)] +mod diagnostic_tests { + use super::FormatError; + use serde_json::json; + + #[test] + fn enum_diagnostic_retains_full_path_and_actual_value() { + let diagnostic = FormatError::InvalidEnumValue { + format: "openai:chat".to_string(), + field: "choices[].finish_reason".to_string(), + value: "future_reason".to_string(), + } + .diagnostic(); + assert_eq!(diagnostic["code"], "invalid_enum_value"); + assert_eq!(diagnostic["path"], "$.choices[*].finish_reason"); + assert_eq!(diagnostic["actual"], "future_reason"); + assert_eq!(diagnostic["format"], "openai:chat"); + } + + #[test] + fn generic_parse_failure_reports_missing_cause_without_a_fake_path() { + let diagnostic = FormatError::ResponseParseFailed { + format: "claude:messages".to_string(), + } + .diagnostic(); + assert_eq!(diagnostic["code"], "response_parse_failed"); + assert_eq!(diagnostic["operation"], "parse_response"); + assert_eq!(diagnostic["path_source"], "unavailable"); + assert_eq!( + diagnostic["missing_context"], + json!(["field_path", "underlying_cause"]) + ); + } +} diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 6983f7e94..a702631ea 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -23,6 +23,9 @@ use crate::formats::shared::stream_core::common::{ }; use crate::formats::shared::AiSurfaceFinalizeError; +const PROVIDER_STREAM_FINISH_ERROR_MESSAGE: &str = + "Upstream stream ended with finish reason: error"; + #[derive(Default)] pub struct StreamingStandardFormatMatrix { provider: Option, @@ -129,7 +132,7 @@ impl StreamingStandardFormatMatrix { { if !canonical_stream_finish_reason_is_supported(finish_reason) { self.terminated = true; - out.extend(client.emit_unsupported_finish_reason(finish_reason)?); + out.extend(client.emit_finish_reason_error(finish_reason)?); break; } } @@ -388,7 +391,13 @@ impl StreamingStandardTerminalObserver { if let Some(parser_error) = finish_reason .as_deref() .filter(|reason| !canonical_stream_finish_reason_is_supported(reason)) - .map(|reason| format!("unsupported provider stream finish reason: {reason}")) + .map(|reason| { + if reason.trim() == "error" { + PROVIDER_STREAM_FINISH_ERROR_MESSAGE.to_string() + } else { + format!("unsupported provider stream finish reason: {reason}") + } + }) { summary.parser_error.get_or_insert(parser_error); } @@ -660,17 +669,28 @@ impl ClientStreamEmitter { self.emit_error(error_body) } - fn emit_unsupported_finish_reason( + fn emit_finish_reason_error( &mut self, finish_reason: &str, ) -> Result, AiSurfaceFinalizeError> { + let (message, code) = if finish_reason.trim() == "error" { + ( + PROVIDER_STREAM_FINISH_ERROR_MESSAGE.to_string(), + "stream_terminal_error", + ) + } else { + ( + format!( + "Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = {}", + serde_json::json!(finish_reason) + ), + "unsupported_finish_reason", + ) + }; let Some(error_body) = build_core_error_body_for_client_format( self.api_format(), - &format!( - "Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = {}", - serde_json::json!(finish_reason) - ), - Some("unsupported_finish_reason"), + &message, + Some(code), LocalCoreSyncErrorKind::ServerError, ) else { return Ok(Vec::new()); @@ -2121,6 +2141,163 @@ mod tests { assert!(sse.contains("\"stop_reason\":\"tool_use\""), "{sse}"); } + #[test] + fn terminal_observer_treats_claude_error_finish_reason_as_upstream_failure() { + for upstream_message in [None, Some("Provider temporarily overloaded")] { + let context = report_context("claude:messages", "claude:messages"); + let mut observer = StreamingStandardTerminalObserver::default(); + observer + .push_line( + &context, + data_line(json!({ + "type": "message_start", + "message": { + "id": "msg_error_finish", + "model": "claude-sonnet-4-5", + "usage": { + "input_tokens": 22, + "cache_read_input_tokens": 7, + "cache_creation_input_tokens": 3, + "cache_creation": { "ephemeral_5m_input_tokens": 3 } + } + } + })), + ) + .expect("message start should be observed"); + if let Some(message) = upstream_message { + observer + .push_line( + &context, + data_line(json!({ + "type": "error", + "error": { "type": "overloaded_error", "message": message } + })), + ) + .expect("upstream error should be observed"); + } + observer + .push_line( + &context, + data_line(json!({ + "type": "message_delta", + "delta": { "stop_reason": "error" }, + "usage": { "output_tokens": 5 } + })), + ) + .expect("error finish reason should be observed"); + let summary = observer + .finish(&context) + .expect("terminal observation should finish") + .expect("failed stream should have a summary"); + + assert!(summary.observed_finish); + assert_eq!(summary.finish_reason.as_deref(), Some("error")); + assert_eq!( + summary.parser_error.as_deref(), + Some(upstream_message.unwrap_or("Upstream stream ended with finish reason: error")) + ); + let usage = summary + .standardized_usage + .expect("usage should be retained"); + assert_eq!(usage.input_tokens, 22); + assert_eq!(usage.output_tokens, 5); + assert_eq!(usage.cache_read_tokens, 7); + assert_eq!(usage.cache_creation_tokens, 3); + assert_eq!(usage.cache_creation_ephemeral_5m_tokens, 3); + } + } + + #[test] + fn transforms_claude_error_finish_reason_to_terminal_errors() { + let cases = [ + ( + "openai:chat", + "data: {\"error\":", + "\"code\":\"stream_terminal_error\"", + ), + ( + "openai:responses", + "event: response.failed\n", + "\"code\":\"stream_terminal_error\"", + ), + ( + "claude:messages", + "event: error\n", + "\"code\":\"stream_terminal_error\"", + ), + ( + "gemini:generate_content", + "data: {\"error\":", + "\"status\":\"INTERNAL\"", + ), + ]; + for (client_api_format, prefix, marker) in cases { + let context = report_context("claude:messages", client_api_format); + let mut matrix = StreamingStandardFormatMatrix::default(); + let mut output = matrix + .transform_line( + &context, + data_line(json!({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "text_delta", "text": "Partial answer" } + })), + ) + .expect("partial response should be emitted"); + output.extend( + matrix + .transform_line( + &context, + data_line(json!({ + "type": "message_delta", + "delta": { "stop_reason": "error" }, + "usage": { "output_tokens": 5 } + })), + ) + .expect("error finish reason should emit a terminal error"), + ); + let sse = String::from_utf8(output).expect("sse should be utf8"); + + assert!(sse.contains("Partial answer"), "{client_api_format}: {sse}"); + assert!(sse.contains(prefix), "{client_api_format}: {sse}"); + assert!(sse.contains(marker), "{client_api_format}: {sse}"); + assert!( + sse.contains("Upstream stream ended with finish reason: error"), + "{client_api_format}: {sse}" + ); + assert!( + !sse.contains("unsupported_finish_reason"), + "{client_api_format}: {sse}" + ); + assert!( + !sse.contains("response.completed"), + "{client_api_format}: {sse}" + ); + assert!( + !sse.contains("\"stop_reason\":\"end_turn\""), + "{client_api_format}: {sse}" + ); + assert!( + !sse.contains("\"finish_reason\":\"stop\""), + "{client_api_format}: {sse}" + ); + assert!(matrix + .transform_line( + &context, + data_line(json!({ + "type": "message_delta", + "delta": { "stop_reason": "end_turn" } + })) + ) + .expect("events after the error should be ignored") + .is_empty()); + assert!(matrix + .finish(&context) + .expect("failed matrix should stay terminated") + .is_empty()); + } + } + #[test] fn transforms_unknown_stream_finish_reasons_to_visible_client_errors() { let cases = [ diff --git a/crates/aether-ai/serving/src/failure_diagnostic.rs b/crates/aether-ai/serving/src/failure_diagnostic.rs index a0bb59158..c31c13be9 100644 --- a/crates/aether-ai/serving/src/failure_diagnostic.rs +++ b/crates/aether-ai/serving/src/failure_diagnostic.rs @@ -34,6 +34,7 @@ pub struct CandidateFailureDiagnostic { client_api_format: Option, provider_api_format: Option, safe_to_show: bool, + details: Option, } impl CandidateFailureDiagnostic { @@ -50,6 +51,7 @@ impl CandidateFailureDiagnostic { client_api_format: None, provider_api_format: None, safe_to_show: true, + details: None, } } @@ -58,6 +60,11 @@ impl CandidateFailureDiagnostic { self } + pub fn details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + pub fn formats( mut self, client_api_format: impl Into, @@ -219,6 +226,10 @@ impl CandidateFailureDiagnostic { "client_api_format": self.client_api_format, "provider_api_format": self.provider_api_format, "safe_to_show": self.safe_to_show, + "details": self.details, + "stage": "request", + "source_format": self.client_api_format, + "target_format": self.provider_api_format, }) } } diff --git a/crates/aether-ai/serving/src/request_body_diagnostics.rs b/crates/aether-ai/serving/src/request_body_diagnostics.rs index 931f016c3..1662e6469 100644 --- a/crates/aether-ai/serving/src/request_body_diagnostics.rs +++ b/crates/aether-ai/serving/src/request_body_diagnostics.rs @@ -224,6 +224,7 @@ fn diagnostic_from_format_error( format_error_path(error), format_error_message(error, client_api_format, provider_api_format), ) + .details(error.diagnostic()) } fn format_error_path(error: &FormatError) -> String { @@ -982,6 +983,23 @@ mod tests { "request_conversion" ); assert_eq!(diagnostic["failure_diagnostic"]["path"], "$.n"); + assert_eq!(diagnostic["failure_diagnostic"]["stage"], "request"); + assert_eq!( + diagnostic["failure_diagnostic"]["details"]["code"], + "lossy_conversion_blocked" + ); + assert_eq!( + diagnostic["failure_diagnostic"]["details"]["path_source"], + "structured" + ); + assert_eq!( + diagnostic["failure_diagnostic"]["source_format"], + "openai:chat" + ); + assert_eq!( + diagnostic["failure_diagnostic"]["target_format"], + "openai:responses" + ); assert_eq!(diagnostic["request_conversion_error"]["path"], "$.n"); assert!(diagnostic["failure_diagnostic"]["message"] .as_str() diff --git a/crates/aether-data/contracts/src/repository/candidates/types.rs b/crates/aether-data/contracts/src/repository/candidates/types.rs index 16a5b1e89..37b4b5b37 100644 --- a/crates/aether-data/contracts/src/repository/candidates/types.rs +++ b/crates/aether-data/contracts/src/repository/candidates/types.rs @@ -857,7 +857,18 @@ pub fn sanitize_request_candidate_extra_data_for_persistence( ("error_flow", &["message"][..]), ( "failure_diagnostic", - &["path", "field_path", "message", "type", "reason"][..], + &[ + "path", + "field_path", + "message", + "type", + "reason", + "details", + "stage", + "source_format", + "target_format", + "safe_to_show", + ][..], ), ( "request_conversion_error", @@ -2445,7 +2456,7 @@ mod tests { let raw = json!({ "upstream_response": {"status_code": 400, "body": "错误内容".repeat(20_000)}, "error_flow": {"status_code": 400, "message": "private upstream failure"}, - "failure_diagnostic": {"path": "$.input", "message": "private conversion failure"}, + "failure_diagnostic": {"path": "$.input", "message": "private conversion failure", "safe_to_show": false, "stage": "request", "details": {"code": "invalid_enum_value", "actual": "private-value"}}, "request_body": {"input": "private prompt"} }); let admin = super::sanitize_request_candidate_extra_data_for_persistence(Some(raw)) @@ -2456,6 +2467,12 @@ mod tests { assert!(body.len() <= 65_536); assert!(body.ends_with("...[truncated]")); assert!(admin.get("request_body").is_none()); + assert_eq!(admin["failure_diagnostic"]["safe_to_show"], false); + assert_eq!(admin["failure_diagnostic"]["stage"], "request"); + assert_eq!( + admin["failure_diagnostic"]["details"]["code"], + "invalid_enum_value" + ); assert_eq!( super::sanitize_request_candidate_extra_data_for_persistence(Some(admin.clone())), Some(admin.clone()), diff --git a/docs/operations/conversion-failure-diagnostics.md b/docs/operations/conversion-failure-diagnostics.md new file mode 100644 index 000000000..f1fec3b1d --- /dev/null +++ b/docs/operations/conversion-failure-diagnostics.md @@ -0,0 +1,42 @@ +# 格式转换失败诊断导出 + +## 使用方法 + +在请求详情的失败或跳过节点中,点击「失败诊断」面板的复制按钮。 +复制时才会读取已采集的正文;页面预览本身不会批量加载正文。 +请分享整个 JSON,而不是只分享 `summary`。复制成功标志仅在剪贴板写入成功后出现。 + +## Schema v2 + +- `diagnostic`:错误码、请求/响应/流式阶段、源/目标格式、转换器标识、完整 JSON 路径及期望约束/实际值。 +- `path_source`:`structured` 是后端结构化路径,`message_inference` 是历史文案推断,`protocol_inference` 是根据上游协议推断的原始字段路径,`unavailable` 表示没有可靠字段路径。通用 `$.finish_reason` 会按协议定位到具体原始字段,同时保留 `reported_path`。 +- `stage_source`:区分后端阶段信息与历史记录推断。请求转换方向为客户端到上游;响应/流式转换方向相反。 +- `versions`:前端版本、导出时网关版本、失败时运行版本。历史记录没有运行版本时保留 `null`,不能把导出版本当作失败版本。 +- `request` / `node`:请求、候选、重试、模型及时间等定位信息。 +- `reproduction.sources`:脱敏正文片段、字段样本和流式失败事件窗口。数组通配路径的样本带具体下标。 +- `reproduction.missing_context`:未采集、无权限、正文过大、读取失败、缺少失败事件或路径等缺口。 + +后端只为明确匹配 `candidate_id` 的候选提供上游正文记录。历史记录仅有候选索引时,不把最后一次重试的正文猜成当前失败的正文。 +原始客户端请求可在同一请求内共享,但不会把其他候选的上游请求/响应当作失败现场。 +`body_ref` 不是下载地址;前端不访问其中的 URL,而是使用现有、受权限保护的正文接口。 + +## 完整性与安全边界 + +- `not_loaded`:尚未补取上下文。 +- `sanitized_context`:必要来源已取得,但仍然经过脱敏、大小限制或事件窗口裁剪。 +- `insufficient_context`:还缺少明确列出的信息,不能据此假设能够完整复现。 +- `replay_ready: false`:导出的是供排查的证据包,不是可以无条件自动执行的请求。修改前应根据样本建立最小回归测试。 + +每份正文的下载/解码处理上限为 1 MiB,读取超时为 5 秒;导出 JSON 上限为 64 Ki 字符。 +字符串、数组、对象深度与节点数量也有限制。流式窗口保留匹配失败的事件、帧序号及邻近事件;匹配不到时明确标记,而不是认定流尾就是故障点。 + +默认移除常见认证头、密钥、令牌、Cookie、密码、签名 URL 参数、正文文本和二进制数据。 +脱敏是规则化处理,分享前仍需检查自定义字段和错误消息是否含业务敏感信息。 +不会为了诊断绕过正文采集策略、授权或存储限制;也不会把 `error` 或未知结束原因映射成正常成功。 + +## 建议处理流程 + +1. 检查 `diagnostic.stage`、`path_source` 和 `missing_context`,区分转换器缺陷、合法的无损转换拒绝和上游失败。 +2. 对照源/目标格式以及字段样本,建立最小失败输入;流式问题同时保留必要的前序事件。 +3. 先补失败回归测试,再修复转换规则。 +4. 验证原有正常映射、失败闭合以及凭据脱敏没有回退。 diff --git a/frontend/src/api/__tests__/dashboard-body-loading.spec.ts b/frontend/src/api/__tests__/dashboard-body-loading.spec.ts index 5c65656c3..451ef108b 100644 --- a/frontend/src/api/__tests__/dashboard-body-loading.spec.ts +++ b/frontend/src/api/__tests__/dashboard-body-loading.spec.ts @@ -4,6 +4,7 @@ const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() })) vi.mock('@/api/client', () => ({ default: { get: getMock } })) import { dashboardApi } from '@/api/dashboard' +import { requestTraceApi } from '@/api/requestTrace' import { cache } from '@/utils/cache' beforeEach(() => { @@ -13,6 +14,20 @@ beforeEach(() => { }) describe('dashboard body loading', () => { + it('reports download progress so diagnostic exports can abort oversized bodies', async () => { + const onProgress = vi.fn() + getMock.mockResolvedValue({ data: new ArrayBuffer(0), headers: { 'x-aether-body-encoding': 'json', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'response_body' } }) + await dashboardApi.getRequestBody('usage-1', 'response_body', undefined, onProgress) + getMock.mock.calls[0][1].onDownloadProgress({ loaded: 2 * 1024 * 1024 }) + expect(onProgress).toHaveBeenCalledWith(2 * 1024 * 1024) + }) + + it('records the gateway version at export without confusing it with failure-time metadata', async () => { + getMock.mockResolvedValue({ data: { request_id: 'request-1', candidates: [] }, headers: { 'x-aether-build-version': 'test-build' } }) + expect(await requestTraceApi.getRequestTrace('request-1')).toMatchObject({ gateway_version: 'test-build' }) + getMock.mockResolvedValue({ data: { request_id: 'request-1', candidates: [] } }) + expect(await requestTraceApi.getRequestTrace('request-1')).toMatchObject({ gateway_version: null }) + }) it('requests opaque body bytes, outside the JSON detail cache', async () => { const bytes = new ArrayBuffer(20) const controller = new AbortController() diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 0e6ae5a91..44e6867eb 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -481,11 +481,12 @@ export const dashboardApi = { return options.signal ? fetchDetail() : cachedRequest(cacheKey, fetchDetail, cacheTtlMs) }, - async getRequestBody(requestId: string, field: RequestBodyField, signal?: AbortSignal) { + async getRequestBody(requestId: string, field: RequestBodyField, signal?: AbortSignal, onProgress?: (loaded: number) => void) { const response = await apiClient.get(`/api/admin/usage/${requestId}`, { params: { include_bodies: true, body_field: field, body_format: 'raw' }, responseType: 'arraybuffer', signal, + ...(onProgress ? { onDownloadProgress: (event: { loaded: number }) => onProgress(event.loaded) } : {}), }) const encoding = response.headers['x-aether-body-encoding'] if ((encoding !== 'gzip' && encoding !== 'json') || response.headers['x-aether-usage-id'] !== requestId || response.headers['x-aether-body-field'] !== field) { diff --git a/frontend/src/api/requestTrace.ts b/frontend/src/api/requestTrace.ts index c5ec0a0e6..868b41dba 100644 --- a/frontend/src/api/requestTrace.ts +++ b/frontend/src/api/requestTrace.ts @@ -121,6 +121,8 @@ export interface CandidateRecord { } export interface RequestTrace { + gateway_version?: string | null + diagnostic_request?: { usage_id: string, body_state?: string | null } | null request_id: string request_path?: string request_query_string?: string @@ -154,7 +156,7 @@ export const requestTraceApi = { const response = await apiClient.get(`/api/admin/monitoring/trace/${requestId}`, { params: { attempted_only: attemptedOnly }, }) - return response.data + return { ...response.data, gateway_version: response.headers?.['x-aether-build-version'] ?? response.data.gateway_version ?? null } }, /** diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index 6b9a08d02..ea86686e7 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -506,19 +506,30 @@ empty-message="无上游响应" /> -
- -
+
+ +
+

+ {{ diagnosticCopying ? '正在读取并脱敏诊断上下文…' : '复制时补取已采集的正文并脱敏;未采集、无权限或超限的信息会明确标注。分享前请检查诊断内容。' }} +

+
/execution runtime (stream )?returned non-success status \d+/i.test(message) +const isStreamTerminalDiagnosticMessage = (message: string): boolean => + /(?:unsupported provider stream finish reason|upstream stream ended with finish reason)\s*:/i.test(message) + +const isStreamFinishErrorDiagnostic = (message: string): boolean => + /(?:unsupported provider stream finish reason|upstream stream ended with finish reason)\s*:\s*error(?:[ \t]*(?:$|\r?\n)|["')])/i.test(message) + const isLocalSyncFinalizeDiagnostic = (message: string): boolean => /local sync attempt failed before terminal finalization/i.test(message) || /unsupported provider stream (event|finish reason)/i.test(message) const isActionableDiagnosticMessage = (message: string): boolean => - isLocalSyncFinalizeDiagnostic(message) || isConversionDiagnosticMessage(message) + isLocalSyncFinalizeDiagnostic(message) + || isStreamTerminalDiagnosticMessage(message) + || isConversionDiagnosticMessage(message) const extractVisibleFailureDiagnostic = ( extra: Record | null | undefined, @@ -1480,10 +1503,8 @@ const extractVisibleFailureDiagnostic = ( const extractVisibleDiagnosticObjects = ( extra: Record | null | undefined, ): Array> => [ - extractVisibleFailureDiagnostic(extra), - extractObject(extra?.request_conversion_error), - extractObject(extra?.request_body_build_error), -].filter((value): value is Record => Boolean(value)) + ...visibleFailureRecords(extra ?? {}), +] const extractVisibleDiagnosticMessage = ( extra: Record | null | undefined, @@ -1555,6 +1576,10 @@ const formatUnsupportedStreamEventMessage = (message: string): string => { } const formatUnsupportedFinishReasonMessage = (message: string): string => { + const terminalMatch = message.match(/^unsupported provider stream finish reason\s*:\s*(.+)$/i) + if (terminalMatch?.[1]) { + return `流式终态校验失败:上游返回了当前不支持的 finish reason(${terminalMatch[1].trim()}),已按失败处理` + } const fieldDetail = extractFieldDetail(message) const legacyMatch = message.match(/unsupported provider stream finish reason\s+(.+?)\s+cannot be converted losslessly/i) const detail = fieldDetail || (legacyMatch?.[1] @@ -1569,7 +1594,7 @@ const formatKnownConversionErrorMessage = (message: string): string => { return `格式转换失败:${formatConversionPair(lossy[1], lossy[2])} 在字段 ${normalizeDiagnosticFieldPath(lossy[3])} 会丢失信息:${lossy[4].trim()}` } - const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+([^:]+):\s*(.+)$/i) + const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+(\S+):\s*(.+)$/i) if (unaudited) { return `格式转换失败:${formatConversionPair(unaudited[2], unaudited[3])} 的字段 ${normalizeDiagnosticFieldPath(unaudited[1])} 尚未审计,不能安全转换:${unaudited[4].trim()}` } @@ -1579,7 +1604,7 @@ const formatKnownConversionErrorMessage = (message: string): string => { return `格式转换失败:${formatApiFormat(unsupportedField[2])} 不支持字段 ${normalizeDiagnosticFieldPath(unsupportedField[1])}:${unsupportedField[3].trim()}` } - const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+(.+)\.([^.\s]+)$/i) + const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+([\w:-]+)\.(.+)$/i) if (invalidEnum) { return `格式转换失败:${formatApiFormat(invalidEnum[2])} 字段 ${normalizeDiagnosticFieldPath(invalidEnum[3])} 的枚举值 ${invalidEnum[1].trim()} 无效` } @@ -1606,7 +1631,7 @@ const formatKnownConversionErrorMessage = (message: string): string => { const isConversionDiagnosticMessage = (message: string): boolean => { const normalized = message.trim() - if (!normalized) return false + if (!normalized || isStreamTerminalDiagnosticMessage(normalized)) return false return /conversion|converted|convertible|cannot be converted|lossy conversion|unsupported field|unaudited field|invalid enum value|invalid target field|unsupported ai format|failed to (parse|emit) .+ (request|response)|unsupported provider stream (event|finish reason)|转换|无损|字段 .*不支持/i .test(normalized) } @@ -1622,6 +1647,9 @@ const formatAttemptErrorMessage = (message: string, statusCode?: number): string if (localSyncInternal?.[1]) { return formatAttemptErrorMessage(decodeRustDebugString(localSyncInternal[1]), statusCode) } + if (isStreamFinishErrorDiagnostic(normalized)) { + return '上游流式响应异常终止:上游返回了错误结束原因(error),已按失败处理' + } if (/unsupported provider stream event cannot be converted losslessly/i.test(normalized)) { return formatUnsupportedStreamEventMessage(normalized) } @@ -1645,9 +1673,7 @@ const shouldShowAttemptMessageWithUpstreamResponse = ( if (!upstreamResponse || !hasRenderableValue(upstreamResponse.body)) return true const normalized = rawMessage.trim() if (!normalized) return false - if (isLocalSyncFinalizeDiagnostic(normalized)) return true - if (isConversionDiagnosticMessage(normalized)) return true - return false + return isActionableDiagnosticMessage(normalized) } const currentAttemptRequestError = computed<{ @@ -1691,8 +1717,7 @@ const currentAttemptRequestError = computed<{ const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra) const shouldAttachDiagnostic = Boolean( visibleDiagnosticObjects.length - || isLocalSyncFinalizeDiagnostic(rawMessage) - || isConversionDiagnosticMessage(rawMessage), + || isActionableDiagnosticMessage(rawMessage), ) const diagnostic = shouldAttachDiagnostic ? buildAttemptDiagnosticPayload( @@ -1736,21 +1761,7 @@ const diagnosticPathFromObject = (value: unknown): string => { || '' } -const diagnosticFieldPathFromMessage = (message: string): string => { - const normalized = message.trim() - if (!normalized) return '' - const fieldMatch = normalized.match(/field\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i) - if (fieldMatch?.[1]) return normalizeDiagnosticFieldPath(fieldMatch[1]) - const lossyMatch = normalized.match(/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i) - if (lossyMatch?.[1]) return normalizeDiagnosticFieldPath(lossyMatch[1]) - const invalidTargetMatch = normalized.match(/invalid target field\s+(.+?)\s+for\s+/i) - if (invalidTargetMatch?.[1]) return normalizeDiagnosticFieldPath(invalidTargetMatch[1]) - const unsupportedFieldMatch = normalized.match(/unsupported field\s+(.+?)\s+in\s+/i) - if (unsupportedFieldMatch?.[1]) return normalizeDiagnosticFieldPath(unsupportedFieldMatch[1]) - const invalidEnumMatch = normalized.match(/invalid enum value\s+.+?\s+for\s+.+\.([^.\s]+)$/i) - if (invalidEnumMatch?.[1]) return normalizeDiagnosticFieldPath(invalidEnumMatch[1]) - return '' -} +const diagnosticFieldPathFromMessage = diagnosticPathFromMessage function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessageOverride = ''): string { const extra = extractObject(attempt.extra_data) @@ -1766,11 +1777,13 @@ function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessage readStringField(requestBodyBuildError ?? {}, 'message') ?? '', typeof attempt.error_message === 'string' ? attempt.error_message : '', ].find(item => item.trim()) ?? '' + const streamFinishReasonPath = isStreamTerminalDiagnosticMessage(rawMessage) ? '$.finish_reason' : '' return diagnosticPathFromObject(failureDiagnostic) || diagnosticPathFromObject(requestConversionError) || diagnosticPathFromObject(requestBodyBuildError) || diagnosticFieldPathFromMessage(rawMessage) + || streamFinishReasonPath || '$' } @@ -1814,6 +1827,12 @@ function buildAttemptDiagnosticPayload( : currentAttemptFormatDisplay.value const analysisHint = (() => { const raw = `${summary}\n${rawMessageForBreakpoint}\n${attempt.error_message ?? ''}`.toLowerCase() + if (isStreamFinishErrorDiagnostic(raw)) { + return '上游通过结束原因报告了流式错误:检查原始 SSE 中的 error/message 及上游日志;保持失败状态,不要映射为正常结束。' + } + if (isStreamTerminalDiagnosticMessage(raw)) { + return '断点在上游流式终态校验:检查原始结束原因及协议兼容性;该错误不代表发生了格式转换,不能确认成功时保持失败闭合。' + } if (raw.includes('unsupported provider stream event')) { return '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。' } @@ -1823,7 +1842,7 @@ function buildAttemptDiagnosticPayload( if (raw.includes('lossy conversion') || raw.includes('无损') || raw.includes('丢失信息') || raw.includes('request_conversion')) { return '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。' } - return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。' + return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / node.error_message 和 upstream_response。' })() const payload = { @@ -1853,15 +1872,82 @@ function buildAttemptDiagnosticPayload( }, raw: { failure_diagnostic: rawFailureDiagnostic, - request_conversion_error: rawRequestConversionError, - request_body_build_error: rawRequestBodyBuildError, + request_conversion_error: rawFailureDiagnostic?.safe_to_show === false || extractObject(extra?.failure_diagnostic)?.safe_to_show === false ? null : rawRequestConversionError, + request_body_build_error: rawFailureDiagnostic?.safe_to_show === false || extractObject(extra?.failure_diagnostic)?.safe_to_show === false ? null : rawRequestBodyBuildError, error_flow: extractObject(extra?.error_flow), upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response), }, } - return payload + return buildFailureDiagnosticBundle(payload, attempt, trace.value, rawMessageForBreakpoint) } +const { copyToClipboard } = useClipboard() +const diagnosticCopying = ref(false) +const diagnosticCopied = ref(false) +const exportedDiagnostic = ref | null>(null) +let diagnosticController: AbortController | null = null +let diagnosticCopyTimer: ReturnType | undefined +const currentAttemptDiagnostic = computed(() => { + const attempt = currentAttempt.value + if (!attempt) return null + if (attempt.status === 'failed') return currentAttemptRequestError.value?.diagnostic ?? null + if (attempt.status !== 'skipped' || !currentAttemptFailureDiagnostic.value) return null + return buildAttemptDiagnosticPayload( + attempt, + currentAttemptFailureDiagnostic.value.message, + attempt.status_code, + normalizeUpstreamResponseDisplay(attempt.extra_data?.upstream_response), + extractVisibleDiagnosticMessage(attempt.extra_data), + ) +}) +const diagnosticDisplay = computed(() => exportedDiagnostic.value + ?? (currentAttemptDiagnostic.value ? sanitizeDiagnostic(currentAttemptDiagnostic.value) as Record : null)) + +function resetDiagnosticCopy() { + diagnosticController?.abort() + diagnosticController = null + clearTimeout(diagnosticCopyTimer) + diagnosticCopying.value = false + diagnosticCopied.value = false + exportedDiagnostic.value = null +} + +async function copyFailureDiagnostic() { + if (!currentAttemptDiagnostic.value || !currentAttempt.value || diagnosticCopying.value) return + const bundle = JSON.parse(JSON.stringify(currentAttemptDiagnostic.value)) as Record + const attempt = JSON.parse(JSON.stringify(currentAttempt.value)) as CandidateRecord + const currentTrace = trace.value ? { ...trace.value, candidates: [] } : null + const controller = new AbortController() + diagnosticController = controller + diagnosticCopying.value = true + diagnosticCopied.value = false + try { + const diagnostic = await prepareDiagnosticExport(bundle, attempt, currentTrace, controller.signal) + if (controller.signal.aborted) return + exportedDiagnostic.value = diagnostic + const copied = await copyToClipboard(JSON.stringify(diagnostic, null, 2)) + if (controller.signal.aborted) return + diagnosticCopied.value = copied + if (copied) diagnosticCopyTimer = setTimeout(() => { diagnosticCopied.value = false }, 2000) + } catch { + if (controller.signal.aborted) return + const fallback = sanitizeDiagnostic({ + ...bundle, + reproduction: { status: 'insufficient_context', missing_context: ['export_failed'] }, + }) as Record + exportedDiagnostic.value = fallback + diagnosticCopied.value = await copyToClipboard(JSON.stringify(fallback, null, 2)) + } finally { + if (diagnosticController === controller) { + diagnosticCopying.value = false + diagnosticController = null + } + } +} + +watch([() => props.requestId, () => currentAttempt.value?.id, () => currentAttempt.value?.error_message], resetDiagnosticCopy) +onBeforeUnmount(resetDiagnosticCopy) + const currentAttemptExtraDataDisplay = computed | null>(() => { const extra = extractObject(currentAttempt.value?.extra_data) if (!extra) return null diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index c2075d330..8a933cc78 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -8,6 +8,13 @@ const requestTraceApiMock = vi.hoisted(() => ({ getRequestTrace: vi.fn(), })) +const diagnosticCopyMock = vi.hoisted(() => ({ prepare: vi.fn(), copy: vi.fn() })) +vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard: diagnosticCopyMock.copy }) })) +vi.mock('@/features/usage/utils/diagnosticExport', async importOriginal => ({ + ...await importOriginal(), + prepareDiagnosticExport: diagnosticCopyMock.prepare, +})) + vi.mock('@/api/requestTrace', () => ({ requestTraceApi: requestTraceApiMock, })) @@ -62,9 +69,16 @@ vi.mock('../JsonContentPanel.vue', async () => { type: String, default: 'JSON', }, + customCopy: Boolean, + copyDisabled: Boolean, + copied: Boolean, }, - setup(props) { - return () => h('pre', { 'data-title': props.title }, JSON.stringify(props.data)) + emits: ['copy'], + setup(props, { emit }) { + return () => h('div', [ + h('pre', { 'data-title': props.title }, JSON.stringify(props.data)), + props.customCopy ? h('button', { 'data-copy-diagnostic': '', 'data-copied': props.copied, disabled: props.copyDisabled, onClick: () => emit('copy') }) : null, + ]) }, }), } @@ -173,6 +187,8 @@ function mountTimelineFromApi( afterEach(() => { requestTraceApiMock.getRequestTrace.mockReset() + diagnosticCopyMock.prepare.mockReset() + diagnosticCopyMock.copy.mockReset() vi.useRealTimers() for (const { app, root } of mountedApps.splice(0)) { app.unmount() @@ -181,6 +197,50 @@ afterEach(() => { }) describe('HorizontalRequestTimeline', () => { + it('exports a skipped conversion failure with context only after clicking copy', async () => { + diagnosticCopyMock.prepare.mockImplementation(async bundle => ({ ...bundle, reproduction: { status: 'sanitized_context' } })) + diagnosticCopyMock.copy.mockResolvedValue(true) + const root = mountTimeline(buildTrace([buildCandidate({ + status: 'skipped', skip_reason: 'provider_request_body_build_failed', + extra_data: { failure_diagnostic: { kind: 'request_conversion', path: '$.n', message: 'lossy conversion blocked from openai:chat to claude:messages at n: multiple outputs' } }, + })])) + await nextTick() + expect(diagnosticCopyMock.prepare).not.toHaveBeenCalled() + const button = root.querySelector('[data-copy-diagnostic]')! + expect(button).not.toBeNull() + button.click() + await flushPendingUpdates() + expect(diagnosticCopyMock.prepare).toHaveBeenCalledTimes(1) + expect(JSON.parse(diagnosticCopyMock.copy.mock.calls[0][0])).toMatchObject({ schema_version: 2, breakpoint: '$.n', reproduction: { status: 'sanitized_context' } }) + expect(button.dataset.copied).toBe('true') + }) + + it('does not report copy success when the clipboard rejects it', async () => { + diagnosticCopyMock.prepare.mockImplementation(async bundle => bundle) + diagnosticCopyMock.copy.mockResolvedValue(false) + const root = mountTimeline(buildTrace([buildCandidate({ error_message: 'unsupported provider stream finish reason: error' })])) + await nextTick() + const button = root.querySelector('[data-copy-diagnostic]')! + button.click() + await flushPendingUpdates() + expect(button.dataset.copied).toBe('false') + }) + + it('cancels in-flight diagnostic exports on unmount', async () => { + let finish: (value: Record) => void = () => undefined + diagnosticCopyMock.prepare.mockImplementation(() => new Promise(resolve => { finish = resolve })) + const root = mountTimeline(buildTrace([buildCandidate({ error_message: 'unsupported provider stream finish reason: error' })])) + await nextTick() + root.querySelector('[data-copy-diagnostic]')!.click() + await nextTick() + mountedApps.splice(mountedApps.findIndex(item => item.root === root), 1)[0].app.unmount() + root.remove() + const signal = diagnosticCopyMock.prepare.mock.calls[0][3] as AbortSignal + expect(signal.aborted).toBe(true) + finish({ reproduction: { status: 'sanitized_context' } }) + await flushPendingUpdates() + expect(diagnosticCopyMock.copy).not.toHaveBeenCalled() + }) it('only renders allowlisted provider website protocols', async () => { const unsafeRoot = mountTimeline(buildTrace([ buildCandidate({ provider_website: 'javascript:alert(document.cookie)' }), @@ -849,6 +909,55 @@ describe('HorizontalRequestTimeline', () => { expect(diagnosticText).toContain('"breakpoint":"$.finish_reason"') }) + it.each([ + 'unsupported provider stream finish reason: error', + 'Upstream stream ended with finish reason: error', + ])('shows an upstream terminal failure rather than a conversion error for %s', async (errorMessage) => { + const trace = buildTrace([ + buildCandidate({ + status: 'failed', + status_code: 200, + error_type: 'stream_terminal_error', + error_message: errorMessage, + extra_data: { + client_api_format: 'claude:messages', + provider_api_format: 'claude:messages', + upstream_response: { status_code: 200, body_state: 'reference' }, + }, + }), + ]) + const root = mountTimeline(trace, { requestApiFormat: 'claude:messages' }) + await nextTick() + + expect(root.querySelector('.error-msg')?.textContent).toContain('上游流式响应异常终止') + expect(root.querySelector('.error-msg')?.textContent).not.toContain('格式转换失败') + const diagnostic = JSON.parse(root.querySelector('.error-diagnostic-json')?.textContent ?? '{}') + expect(diagnostic.breakpoint).toBe('$.delta.stop_reason') + expect(diagnostic.analysis_hint).toContain('不要映射为正常结束') + expect(diagnostic.analysis_hint).not.toContain('finish_reason 映射') + expect(diagnostic.node.status_code).toBe(200) + expect(diagnostic.node.error_message).toBe(errorMessage) + }) + + it('distinguishes terminal validation from an actual finish reason conversion failure', async () => { + const trace = buildTrace([ + buildCandidate({ + status_code: 200, + error_type: 'stream_terminal_error', + error_message: 'unsupported provider stream finish reason: future_reason', + }), + ]) + const root = mountTimeline(trace) + await nextTick() + + expect(root.querySelector('.error-msg')?.textContent).toContain('流式终态校验失败') + expect(root.querySelector('.error-msg')?.textContent).toContain('future_reason') + expect(root.querySelector('.error-msg')?.textContent).not.toContain('格式转换失败') + const diagnostic = JSON.parse(root.querySelector('.error-diagnostic-json')?.textContent ?? '{}') + expect(diagnostic.breakpoint).toBe('$.finish_reason') + expect(diagnostic.analysis_hint).toContain('上游流式终态校验') + }) + it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => { const trace = buildTrace([ buildCandidate({ diff --git a/frontend/src/features/usage/utils/__tests__/diagnosticExport.spec.ts b/frontend/src/features/usage/utils/__tests__/diagnosticExport.spec.ts new file mode 100644 index 000000000..ca8055af2 --- /dev/null +++ b/frontend/src/features/usage/utils/__tests__/diagnosticExport.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CandidateRecord, RequestTrace } from '@/api/requestTrace' +import { diagnosticSamples, prepareDiagnosticExport, sanitizeDiagnostic } from '../diagnosticExport' + +const candidate = (extra: Record = {}): CandidateRecord => ({ + id: 'failed-candidate', request_id: 'request-1', candidate_index: 0, retry_index: 0, + status: 'failed', is_cached: false, created_at: '2026-09-08T00:00:00Z', extra_data: extra, +}) +const trace: RequestTrace = { + request_id: 'request-1', final_status: 'failed', total_candidates: 1, total_latency_ms: 0, candidates: [], + diagnostic_request: { usage_id: 'usage-1', body_state: 'reference' }, +} +const bundle = (stage = 'response', path = '$.stop_reason', actual: unknown = 'future') => ({ + schema_version: 2, summary: 'conversion failed', request: { request_id: 'request-1' }, + diagnostic: { stage, path, actual, code: 'invalid_enum_value', missing_context: [] }, +}) +const context = { usage_id: 'usage-1', body_states: { request_body: 'reference', provider_request_body: 'disabled', response_body: 'reference' } } + +describe('diagnostic export', () => { + it('loads only the matched usage and preserves concrete field evidence', async () => { + const loader = vi.fn(async (_usageId: string, field: string) => field === 'request_body' + ? { messages: [{ role: 'user', content: 'private customer text' }] } + : { stop_reason: 'future', authorization: 'opaque-secret', message: 'failed with opaque-secret' }) + const result = await prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, new AbortController().signal, loader) + expect(loader.mock.calls).toEqual([['usage-1', 'request_body', expect.any(AbortSignal)], ['usage-1', 'response_body', expect.any(AbortSignal)]]) + expect(result.reproduction).toMatchObject({ status: 'sanitized_context', replay_ready: false, sources: { + response_body: { field_samples: [{ path: '$.stop_reason', value: { stop_reason: 'future' } }] }, + } }) + expect(JSON.stringify(result)).not.toContain('opaque-secret') + expect(JSON.stringify(result)).not.toContain('private customer text') + }) + + it('never substitutes the final attempt response for a different failed candidate', async () => { + const loader = vi.fn(async () => ({ messages: [] })) + const result = await prepareDiagnosticExport(bundle(), candidate(), trace, new AbortController().signal, loader) + expect(loader).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledWith('usage-1', 'request_body', expect.any(AbortSignal)) + expect(result.reproduction).toMatchObject({ status: 'insufficient_context', missing_context: ['response_body'], sources: { response_body: { status: 'unavailable' } } }) + }) + + it('does not follow body_ref URLs or fetch disabled bodies', async () => { + const loader = vi.fn() + const result = await prepareDiagnosticExport(bundle(), candidate({ + upstream_response: { body_ref: 'https://untrusted.example/private' }, + diagnostic_context: { usage_id: 'usage-1', body_states: { request_body: 'disabled', response_body: 'disabled', provider_request_body: 'disabled' } }, + }), null, new AbortController().signal, loader) + expect(loader).not.toHaveBeenCalled() + expect(result.reproduction).toMatchObject({ status: 'insufficient_context', sources: { response_body: { status: 'disabled' } } }) + }) + + it.each([[403, 'forbidden'], [404, 'missing'], [500, 'load_failed']])('exports an explicit gap for HTTP %s', async (status, expected) => { + const loader = vi.fn().mockRejectedValue({ response: { status } }) + const result = await prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, new AbortController().signal, loader) + expect(result.reproduction).toMatchObject({ status: 'insufficient_context', sources: { response_body: { status: expected } } }) + }) + + it('keeps the failing SSE event, frame index and preceding context without leaking text', async () => { + const raw = [ + ['message_start', { type: 'message_start', message: { id: 'msg-1' } }], + ['content_block_delta', { type: 'content_block_delta', delta: { type: 'text_delta', text: 'confidential content' } }], + ['message_delta', { type: 'message_delta', delta: { stop_reason: 'error' } }], + ['message_stop', { type: 'message_stop' }], + ].map(([event, payload]) => `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`).join('') + const result = await prepareDiagnosticExport(bundle('stream', '$.delta.stop_reason', 'error'), candidate({ upstream_response: { body: { raw_response: raw } } }), null, new AbortController().signal, vi.fn()) + expect(result.reproduction).toMatchObject({ status: 'sanitized_context', sources: { response_body: { sample: { failure_frame_index: 2, selection: 'matched_failure', captured_frames: 4 } } } }) + expect(JSON.stringify(result)).toContain('message_start') + expect(JSON.stringify(result)).not.toContain('confidential content') + }) + + it('marks a truncated stream without the failing event as incomplete', async () => { + const result = await prepareDiagnosticExport(bundle('stream', '$.type', 'future.event'), candidate({ upstream_response: { body: { raw_response: 'event: message_start\ndata: {"type":"message_start"}\n\n' } } }), null, new AbortController().signal, vi.fn()) + expect(result.reproduction).toMatchObject({ status: 'insufficient_context', missing_context: ['failed_stream_event'] }) + }) + + it('cancels instead of copying another candidate after navigation', async () => { + const controller = new AbortController() + const loader = vi.fn(async () => { controller.abort(); return {} }) + await expect(prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, controller.signal, loader)).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('identifies the actual failing element beyond the initial excerpt', () => { + const choices = Array.from({ length: 50 }, (_value, index) => ({ finish_reason: index === 40 ? 'future' : 'stop' })) + expect(diagnosticSamples({ choices }, '$.choices[*].finish_reason', 'future')).toEqual([{ path: '$.choices[40].finish_reason', value: { finish_reason: 'future' } }]) + }) + + it('redacts credentials, signed URLs, private text and error echoes', () => { + const result = sanitizeDiagnostic({ + diagnostic: { path: '$.api_key', actual: 'plain-secret-value' }, + summary: 'invalid enum value "plain-secret-value" for api_key', + raw: { headers: { Authorization: 'Bearer authorization-private-value', Cookie: 'session=abc', 'x-api-key': 'plain-secret-value', 'x-goog-api-key': 'google-private-key', 'x-auth-token': 'provider-private-token' }, + error: 'Bearer other-token https://user:password@example.com/test?token=abc admin@example.com', + content: 'private customer text', + }, + }) + const output = JSON.stringify(result) + for (const privateValue of ['plain-secret-value', 'authorization-private-value', 'session=abc', 'other-token', 'password@', 'token=abc', 'admin@example.com', 'private customer text', 'google-private-key', 'provider-private-token']) expect(output).not.toContain(privateValue) + }) + + it('bounds the exported payload even for very large inline responses', async () => { + const body = Object.fromEntries(Array.from({ length: 64 }, (_value, index) => [`field${index}`, 'value'.repeat(10000)])) + const result = await prepareDiagnosticExport(bundle(), candidate({ upstream_response: { body } }), null, new AbortController().signal, vi.fn()) + expect(JSON.stringify(result, null, 2).length).toBeLessThanOrEqual(64 * 1024) + expect(result.reproduction).toMatchObject({ status: 'insufficient_context' }) + }) + + it('never drops the failure frame when many earlier block starts match', async () => { + const events = Array.from({ length: 30 }, () => ({ type: 'content_block_start', index: 0 })) + const raw = [...events, { type: 'content_block_delta', index: 0, delta: { type: 'future_delta' } }] + .map(payload => `event: ${payload.type}\ndata: ${JSON.stringify(payload)}\n\n`).join('') + const result = await prepareDiagnosticExport(bundle('stream', '$.delta.type', 'future_delta'), candidate({ upstream_response: { body: { raw_response: raw } } }), null, new AbortController().signal, vi.fn()) + expect(JSON.stringify(result)).toContain('future_delta') + expect(JSON.stringify(result)).toContain('"frame_index":30') + }) + + it.each(['too_large', 'decode_failed', 'timeout'])('retains the specific body failure code %s', async code => { + const result = await prepareDiagnosticExport(bundle(), candidate({ diagnostic_context: context }), trace, new AbortController().signal, vi.fn().mockRejectedValue(new Error(code))) + expect(result.reproduction).toMatchObject({ status: 'insufficient_context', sources: { response_body: { status: code } } }) + }) +}) diff --git a/frontend/src/features/usage/utils/__tests__/failureDiagnostic.spec.ts b/frontend/src/features/usage/utils/__tests__/failureDiagnostic.spec.ts new file mode 100644 index 000000000..d6656d47f --- /dev/null +++ b/frontend/src/features/usage/utils/__tests__/failureDiagnostic.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import type { CandidateRecord } from '@/api/requestTrace' +import { buildFailureDiagnosticBundle, diagnosticPathFromMessage, visibleFailureRecords } from '../failureDiagnostic' + +const candidate = (overrides: Partial = {}): CandidateRecord => ({ + id: 'candidate-1', request_id: 'request-1', candidate_index: 0, retry_index: 0, + status: 'failed', is_cached: false, created_at: '2026-09-08T00:00:00Z', + error_type: 'local_sync_attempt_aborted', ...overrides, +}) + +const payload = { + breakpoint: '$', + request: { client_api_format: 'openai:chat', provider_api_format: 'claude:messages' }, +} + +describe('failure diagnostic paths', () => { + it.each([ + ['invalid enum value "future" for claude:messages.content[2].type', '$.content[2].type'], + ['invalid enum value "future" for openai:chat.choices[].finish_reason', '$.choices[*].finish_reason'], + ['unaudited field metadata.private in openai:responses cannot be converted to claude:messages: no mapping', '$.metadata.private'], + ['lossy conversion blocked from openai:chat to claude:messages at messages[1].content[0].type: cannot preserve', '$.messages[1].content[0].type'], + ['Internal("invalid enum value \\"future\\" for claude:messages.content[2].type")', '$.content[2].type'], + ['Local sync attempt failed before terminal finalization: Internal("invalid enum value \\"future\\" for claude:messages.content[2].type")', '$.content[2].type'], + ['unsupported field tools[12].function.strict in claude:messages: unsupported', '$.tools[12].function.strict'], + ['failed to parse claude:messages response', ''], + ])('extracts the complete path from %s', (message, path) => { + expect(diagnosticPathFromMessage(message)).toBe(path) + }) + + it('prefers structured diagnostics over legacy text and preserves the actual value', () => { + const result = buildFailureDiagnosticBundle(payload, candidate({ + status: 'skipped', + error_message: 'failed to parse claude:messages request', + extra_data: { failure_diagnostic: { + stage: 'request', source: 'request_converter', path: '$.tools[3].type', + details: { code: 'invalid_enum_value', actual: 'future', path: '$.tools[3].type' }, + } }, + })) + expect(result.schema_version).toBe(2) + expect(result.diagnostic).toMatchObject({ + code: 'invalid_enum_value', stage: 'request', stage_source: 'structured', + path: '$.tools[3].type', path_source: 'structured', actual: 'future', + source_format: 'openai:chat', target_format: 'claude:messages', converter: 'request_converter', + }) + }) + + it('uses the reverse format direction for response conversion', () => { + const result = buildFailureDiagnosticBundle(payload, candidate({ error_message: 'invalid enum value "future" for claude:messages.stop_reason' })) + expect(result.diagnostic).toMatchObject({ + stage: 'response', path_source: 'message_inference', actual: 'future', + source_format: 'claude:messages', target_format: 'openai:chat', + }) + }) + + it.each([ + ['claude:messages', '$.delta.stop_reason'], + ['openai:chat', '$.choices[*].finish_reason'], + ['gemini:generate_content', '$.candidates[*].finishReason'], + ])('resolves canonical finish reasons to the %s source field', (providerFormat, path) => { + const result = buildFailureDiagnosticBundle({ + breakpoint: '$.finish_reason', + request: { client_api_format: 'claude:messages', provider_api_format: providerFormat }, + }, candidate({ error_type: 'stream_terminal_error', error_message: 'unsupported provider stream finish reason: future_reason' })) + expect(result.diagnostic).toMatchObject({ + stage: 'stream', code: 'unsupported_finish_reason', actual: 'future_reason', + reported_path: '$.finish_reason', path, path_source: 'protocol_inference', + }) + }) + + it('does not replace a structured finish reason path with a protocol guess', () => { + const result = buildFailureDiagnosticBundle(payload, candidate({ + error_type: 'stream_terminal_error', error_message: 'unsupported provider stream finish reason: future_reason', + extra_data: { failure_diagnostic: { stage: 'stream', path: '$.message.stop_reason' } }, + })) + expect(result.diagnostic).toMatchObject({ path: '$.message.stop_reason', path_source: 'structured' }) + }) + + it('keeps generic parse failures explicitly incomplete instead of inventing a path', () => { + const result = buildFailureDiagnosticBundle(payload, candidate({ error_message: 'failed to parse claude:messages response' })) + expect(result.diagnostic).toMatchObject({ code: 'response_parse_failed', path: '$', path_source: 'unavailable', missing_context: ['field_path'] }) + expect(result.reproduction).toMatchObject({ status: 'not_loaded' }) + }) + + it('uses the selected error-flow message rather than a generic fallback', () => { + const result = buildFailureDiagnosticBundle(payload, candidate({ error_message: 'execution runtime returned non-success status 500' }), null, + 'unaudited field metadata.private in claude:messages cannot be converted to openai:chat: no mapping') + expect(result.diagnostic).toMatchObject({ code: 'unaudited_field', path: '$.metadata.private' }) + }) + + it('does not expose hidden diagnostics through compatibility aliases', () => { + expect(visibleFailureRecords({ + failure_diagnostic: { safe_to_show: false, message: 'private' }, + request_conversion_error: { message: 'private' }, + request_body_build_error: { message: 'private' }, + })).toEqual([]) + }) +}) diff --git a/frontend/src/features/usage/utils/diagnosticExport.ts b/frontend/src/features/usage/utils/diagnosticExport.ts new file mode 100644 index 000000000..5ca0cfe23 --- /dev/null +++ b/frontend/src/features/usage/utils/diagnosticExport.ts @@ -0,0 +1,262 @@ +import { dashboardApi, type RequestBodyField } from '@/api/dashboard' +import type { CandidateRecord, RequestTrace } from '@/api/requestTrace' +import { decodeBody } from './body-document-engine' +import { diagnosticObject } from './failureDiagnostic' + +const MAX_SOURCE_BYTES = 1024 * 1024 +const MAX_EXPORT_CHARS = 64 * 1024 +const SECRET_KEY = /^(?:authorization|proxy[-_]authorization|cookie|set[-_]cookie|(?:x[-_])?api[-_]?key|x[-_]goog[-_]api[-_]key|(?:openai|anthropic)[-_]api[-_]?key|(?:x[-_])?auth[-_]token|api[-_]secret|secret[-_]key|session(?:[-_]id)?|key|token|access[-_]token|refresh[-_]token|id[-_]token|client[-_]secret|secret|password|passwd|credential|credentials|private[-_]key)$/i +const CONTENT_KEY = /^(?:text|content|prompt|system|instructions|input|arguments|partial_json|thinking|signature|data|image|url|image_url|audio|video|raw_response|body|description|refusal)$/i + +function redactString(value: string): string { + return value + .replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9_+./=:-]+/gi, '[REDACTED_AUTH]') + .replace(/\b(?:sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/g, '[REDACTED_TOKEN]') + .replace(/\b(api[_-]?key|access_token|refresh_token|password|secret)\s*[=:]\s*[^\s&,;"']+/gi, '$1=[REDACTED]') + .replace(/https?:\/\/[^\s"<>]+/gi, value => { + try { + const url = new URL(value) + url.username = '' + url.password = '' + if (url.search) url.search = '?REDACTED' + url.hash = '' + return url.toString() + } catch { return '[REDACTED_URL]' } + }) + .replace(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi, '[REDACTED_EMAIL]') + .replace(/data:[^;\s]+;base64,[A-Za-z0-9+/=]+/gi, '[REDACTED_BINARY]') +} + +export function sanitizeDiagnostic(value: unknown): unknown { + const secrets = new Set() + let scanned = 2500 + const collect = (input: unknown, key = '', depth = 0) => { + if (--scanned < 0 || depth > 16) return + if (SECRET_KEY.test(key) && typeof input === 'string') secrets.add(input.slice(0, 4096)) + if (Array.isArray(input)) input.slice(0, 64).forEach(child => collect(child, key, depth + 1)) + else if (input && typeof input === 'object') Object.entries(input).slice(0, 64).forEach(([name, child]) => collect(child, name, depth + 1)) + } + collect(value) + const originalDiagnostic = diagnosticObject(diagnosticObject(value).diagnostic) + if (typeof originalDiagnostic.path === 'string' && originalDiagnostic.path.split(/[.[\]]/).some(part => SECRET_KEY.test(part))) { + if (typeof originalDiagnostic.actual === 'string') secrets.add(originalDiagnostic.actual) + } + let remaining = 1200 + const visit = (input: unknown, key = '', depth = 0): unknown => { + if (--remaining < 0 || depth > 16) return '[TRUNCATED]' + if (SECRET_KEY.test(key)) return '[REDACTED]' + if (typeof input === 'string') { + if (CONTENT_KEY.test(key)) return `[REDACTED_TEXT length=${input.length}]` + let redacted = input.slice(0, 2048) + for (const secret of secrets) { + if (secret.length >= 4) { + const prefix = secret.slice(0, 32) + for (let count = 0; count < 16; count++) { + const index = redacted.indexOf(prefix) + if (index < 0) break + redacted = `${redacted.slice(0, index)}[REDACTED]${redacted.slice(index + secret.length)}` + } + } + else if (secret && redacted === secret) redacted = '[REDACTED]' + } + redacted = redactString(redacted) + return input.length > 2048 ? `${redacted}[TRUNCATED]` : redacted + } + if (Array.isArray(input)) { + const output = input.slice(0, 32).map(item => visit(item, key, depth + 1)) + if (input.length > 32) output.push(`[TRUNCATED ${input.length - 32} items]`) + return output + } + if (input && typeof input === 'object') { + const entries = Object.entries(input).slice(0, 64) + const output: Record = {} + for (const [name, child] of entries) { + if (['__proto__', 'constructor', 'prototype'].includes(name)) continue + output[redactString(name.slice(0, 128))] = visit(child, name, depth + 1) + } + if (Object.keys(input).length > 64) output.__truncated__ = true + return output + } + return input + } + const sanitized = visit(value) + const safe = diagnosticObject(sanitized) + const diagnostic = diagnosticObject(safe.diagnostic) + if (typeof diagnostic.path === 'string' && diagnostic.path.split(/[.[\]]/).some(part => SECRET_KEY.test(part))) { + diagnostic.actual = '[REDACTED]' + } + return sanitized +} + +export function diagnosticSamples(body: unknown, path: string, actual?: unknown): Array<{ path: string, value: unknown }> { + if (!/^\$(?:\.[\w:-]+|\[(?:\d+|\*)\])+$/.test(path)) return [] + const parts = path.slice(1).replace(/\[(\d+|\*)\]/g, '.$1').split('.').filter(Boolean) + let matches: Array<{ path: string, value: unknown }> = [{ path: '$', value: body }] + for (const part of parts) { + matches = matches.flatMap(match => { + if (part === '*' && Array.isArray(match.value)) { + return match.value.slice(0, 64).map((value, index) => ({ path: `${match.path}[${index}]`, value })) + } + if (match.value === null || typeof match.value !== 'object' || !Object.prototype.hasOwnProperty.call(match.value, part)) return [] + const value = (match.value as Record)[part] + return [{ path: Array.isArray(match.value) ? `${match.path}[${part}]` : `${match.path}.${part}`, value }] + }).slice(0, 64) + } + return matches.filter(match => actual == null || Object.is(match.value, actual)).slice(0, 8).map(match => ({ + path: match.path, + value: match.path.split(/[.[\]]/).some(part => SECRET_KEY.test(part)) ? '[REDACTED]' : sanitizeDiagnostic({ [parts[parts.length - 1] ?? 'value']: match.value }), + })) +} + +function streamEvidence(body: unknown, path: string, actual: unknown): Record | null { + const object = diagnosticObject(body) + const raw = typeof body === 'string' ? body : object.raw_response ?? object.sse + if (typeof raw !== 'string' || !/(?:^|\n)(?:event|data):/.test(raw)) return null + const events = raw.slice(0, MAX_SOURCE_BYTES).split(/\r?\n\r?\n/).flatMap((block, index) => { + const lines = block.split(/\r?\n/) + const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n') + if (!data || data === '[DONE]') return [] + let payload: unknown + try { payload = JSON.parse(data) } catch { payload = { unparsed: true, bytes: data.length } } + return [{ frame_index: index, event: lines.find(line => line.startsWith('event:'))?.slice(6).trim() ?? diagnosticObject(payload).type ?? null, payload }] + }) + const targetIndex = events.findIndex(event => { + const payload = diagnosticObject(event.payload) + if (actual !== null && actual !== undefined) { + const samples = diagnosticSamples(payload, path, actual) + if (samples.some(sample => Object.values(diagnosticObject(sample.value)).some(value => value === actual))) return true + } + return event.event === 'error' || event.event === 'response.failed' || Boolean(payload.error) + }) + const center = targetIndex >= 0 ? targetIndex : Math.max(0, events.length - 1) + const selected = new Set([0]) + for (let index = Math.max(0, center - 3); index <= Math.min(events.length - 1, center + 1); index++) selected.add(index) + const target = diagnosticObject(events[center]?.payload) + for (let index = center - 1; index >= 0; index--) { + const event = events[index] + if (event.event === 'content_block_start' && diagnosticObject(event.payload).index === target.index) { + selected.add(index) + break + } + } + return { + captured_frames: events.length, + source_truncated: raw.length > MAX_SOURCE_BYTES, + failure_frame_index: targetIndex >= 0 ? events[targetIndex]?.frame_index : null, + selection: targetIndex >= 0 ? 'matched_failure' : 'unmatched_tail', + windowed: selected.size < events.length, + events: [...selected].sort((left, right) => left - right).slice(0, 12).map(index => events[index]).filter(Boolean), + } +} + +type BodyLoader = (usageId: string, field: RequestBodyField, signal: AbortSignal) => Promise + +async function loadBody(usageId: string, field: RequestBodyField, signal: AbortSignal): Promise { + const controller = new AbortController() + let tooLarge = false + const abort = () => controller.abort() + signal.addEventListener('abort', abort, { once: true }) + const timer = setTimeout(abort, 5000) + try { + if (signal.aborted) throw new DOMException('Aborted', 'AbortError') + const response = await dashboardApi.getRequestBody(usageId, field, controller.signal, loaded => { + if (loaded > MAX_SOURCE_BYTES) { + tooLarge = true + controller.abort() + } + }) + if (response.bytes.byteLength > MAX_SOURCE_BYTES) throw new Error('too_large') + return (await decodeBody(response.bytes, response.encoding, MAX_SOURCE_BYTES)).value + } catch (error) { + if (tooLarge) throw new Error('too_large') + if (controller.signal.aborted && !signal.aborted) throw new Error('timeout') + throw error + } finally { + clearTimeout(timer) + signal.removeEventListener('abort', abort) + } +} + +export async function prepareDiagnosticExport( + bundle: Record, + attempt: CandidateRecord, + trace: RequestTrace | null, + signal: AbortSignal, + loader: BodyLoader = loadBody, +): Promise> { + const diagnostic = diagnosticObject(bundle.diagnostic) + const stage = diagnostic.stage + const context = diagnosticObject(attempt.extra_data?.diagnostic_context) + const states = diagnosticObject(context.body_states) + const primary = stage === 'request' ? 'request_body' : 'response_body' + const fields: RequestBodyField[] = stage === 'request' ? ['request_body', 'provider_request_body'] : ['request_body', 'provider_request_body', 'response_body'] + const sources: Record = {} + const missing = Array.isArray(diagnostic.missing_context) ? [...diagnostic.missing_context] : [] + for (const field of fields) { + if (signal.aborted) throw new DOMException('Aborted', 'AbortError') + const usageId = field === 'request_body' ? trace?.diagnostic_request?.usage_id ?? context.usage_id : context.usage_id + const state = field === 'request_body' ? trace?.diagnostic_request?.body_state ?? states[field] : states[field] + const inline = field === 'response_body' ? diagnosticObject(attempt.extra_data?.upstream_response).body : undefined + if (inline === undefined && (typeof usageId !== 'string' || !usageId || ['disabled', 'none'].includes(String(state)))) { + sources[field] = { status: state === 'disabled' ? 'disabled' : 'unavailable', candidate_matched: Boolean(context.usage_id) } + if (field === primary) missing.push(field) + continue + } + try { + const body = inline !== undefined ? inline : await loader(usageId as string, field, signal) + if (signal.aborted) throw new DOMException('Aborted', 'AbortError') + const sse = field === 'response_body' && stage === 'stream' ? streamEvidence(body, String(diagnostic.path ?? '$'), diagnostic.actual) : null + const samples = field === primary ? diagnosticSamples(body, String(diagnostic.path ?? '$'), diagnostic.actual) : [] + sources[field] = { + status: 'captured', + origin: inline !== undefined ? 'candidate_inline' : 'stored_body', + usage_id: usageId ?? null, + sample: sse ?? body, + field_samples: samples, + } + if (field === primary && stage === 'stream' && (!sse || sse.failure_frame_index === null)) missing.push('failed_stream_event') + if (sse?.source_truncated) missing.push('body_size_limit') + if (field === primary && state === 'truncated') missing.push('body_capture_truncated') + if (field === primary && stage !== 'stream' && diagnostic.path !== '$' && samples.length === 0) missing.push('field_not_found_in_source') + } catch (error) { + if (signal.aborted) throw new DOMException('Aborted', 'AbortError') + const response = diagnosticObject(diagnosticObject(error).response) + const status = response.status + const cause = diagnosticObject(response.headers)['x-aether-body-error'] ?? diagnosticObject(error).code ?? (error instanceof Error ? error.message : '') + const code = status === 403 ? 'forbidden' : status === 404 ? 'missing' : ['too_large', 'decode_failed', 'timeout', 'missing', 'storage_unavailable'].includes(String(cause)) ? String(cause) : 'load_failed' + sources[field] = { status: code } + if (field === primary) missing.push(field) + } + } + const result = sanitizeDiagnostic({ + ...bundle, + reproduction: { + status: missing.length ? 'insufficient_context' : 'sanitized_context', + replay_ready: false, + missing_context: [...new Set(missing)], + redaction: 'Credentials, private text, URLs and binary data are removed; samples may be truncated. Review before sharing.', + source_limit_bytes: MAX_SOURCE_BYTES, + sources, + }, + }) as Record + if (JSON.stringify(result, null, 2).length > MAX_EXPORT_CHARS) { + const reproduction = diagnosticObject(result.reproduction) + reproduction.status = 'insufficient_context' + reproduction.missing_context = [...new Set([...missing, 'export_size_limit'])] + for (const source of Object.values(diagnosticObject(reproduction.sources))) { + const object = diagnosticObject(source) + object.sample = '[TRUNCATED_EXPORT_SIZE_LIMIT]' + object.field_samples = '[TRUNCATED_EXPORT_SIZE_LIMIT]' + } + result.raw = '[TRUNCATED_EXPORT_SIZE_LIMIT]' + if (JSON.stringify(result, null, 2).length > MAX_EXPORT_CHARS) { + return { + schema_version: 2, + summary: '[TRUNCATED_EXPORT_SIZE_LIMIT]', + diagnostic: { code: diagnosticObject(result.diagnostic).code, stage: diagnosticObject(result.diagnostic).stage, path: diagnosticObject(result.diagnostic).path }, + reproduction: { status: 'insufficient_context', missing_context: ['export_size_limit'] }, + } + } + } + return result +} diff --git a/frontend/src/features/usage/utils/failureDiagnostic.ts b/frontend/src/features/usage/utils/failureDiagnostic.ts new file mode 100644 index 000000000..67aec821c --- /dev/null +++ b/frontend/src/features/usage/utils/failureDiagnostic.ts @@ -0,0 +1,168 @@ +import type { CandidateRecord, RequestTrace } from '@/api/requestTrace' + +type JsonObject = Record + +export const diagnosticObject = (value: unknown): JsonObject => + value !== null && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} + +const text = (value: unknown): string => typeof value === 'string' ? value.trim() : '' + +export function unwrapDiagnosticMessage(message: string): string { + let result = message.trim() + for (let depth = 0; depth < 4; depth++) { + const wrapped = result.match(/^(?:local sync attempt failed before terminal finalization:\s*)?Internal\("((?:\\.|[^"\\])*)"\)$/i) + if (!wrapped) break + try { + result = JSON.parse(`"${wrapped[1]}"`) as string + } catch { + break + } + } + return result +} + +export function normalizeFailurePath(field: string): string { + const normalized = field.trim().replace(/\[\]/g, '[*]') + return !normalized || normalized === '$' ? '$' : normalized.startsWith('$') ? normalized : `$.${normalized}` +} + +export function diagnosticPathFromMessage(message: string): string { + const normalized = unwrapDiagnosticMessage(message) + const patterns = [ + /\bfield\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i, + /lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i, + /(?:unsupported|unaudited) field\s+(.+?)\s+in\s+/i, + /invalid target field\s+(.+?)\s+for\s+/i, + /invalid enum value\s+.+?\s+for\s+[\w:-]+\.(.+)$/i, + ] + for (const pattern of patterns) { + const match = normalized.match(pattern) + if (match?.[1]) return normalizeFailurePath(match[1]) + } + return '' +} + +export function visibleFailureRecords(extra: JsonObject): JsonObject[] { + if (diagnosticObject(extra.failure_diagnostic).safe_to_show === false) return [] + return ['failure_diagnostic', 'request_conversion_error', 'request_body_build_error'] + .map(key => diagnosticObject(extra[key])) + .filter(record => Object.keys(record).length > 0 && record.safe_to_show !== false) +} + +export function buildFailureDiagnosticBundle( + payload: JsonObject, + attempt: CandidateRecord, + trace?: RequestTrace | null, + rawMessage = '', +): JsonObject { + const extra = diagnosticObject(attempt.extra_data) + const structured = visibleFailureRecords(extra)[0] ?? {} + const details = diagnosticObject(structured.details) + const request = diagnosticObject(payload.request) + const diagnosticContext = diagnosticObject(extra.diagnostic_context) + const message = unwrapDiagnosticMessage(rawMessage || text(structured.message) || text(attempt.error_message) || text(payload.summary)) + const clientFormat = text(request.client_api_format) || text(structured.client_api_format) + const providerFormat = text(request.provider_api_format) || text(structured.provider_api_format) || text(attempt.endpoint_name) + let stage = text(structured.stage) + if (!['request', 'response', 'stream'].includes(stage)) stage = '' + const stageSource = stage ? 'structured' : 'inferred' + if (!stage) { + if (/stream/i.test(attempt.error_type ?? '') || /provider stream|stream ended/i.test(message)) stage = 'stream' + else if (attempt.status === 'skipped' || /request|body_rules|header_rules/.test(text(structured.kind)) || /failed to (?:parse|emit) .+ request/i.test(message)) stage = 'request' + else if (/local_sync|finaliz|response/i.test(attempt.error_type ?? '') || /failed to (?:parse|emit) .+ response/i.test(message)) stage = 'response' + else stage = 'unknown' + } + const conversionPair = message.match(/(?:lossy conversion blocked from|unaudited field .+? in)\s+(\S+)\s+(?:to|cannot be converted to)\s+([^\s:]+:[^\s:]+(?:[:][^\s:]+)?)/i) + if (stage === 'unknown' && conversionPair && clientFormat !== providerFormat) { + if (conversionPair[1] === clientFormat && conversionPair[2] === providerFormat) stage = 'request' + else if (conversionPair[1] === providerFormat && conversionPair[2] === clientFormat) stage = 'response' + } + let code = text(details.code) || text(structured.code) + if (!code) { + const patterns: Array<[RegExp, string]> = [ + [/(?:unsupported provider stream finish reason|upstream stream ended with finish reason):\s*error\s*$/i, 'stream_terminal_error'], + [/unsupported provider stream finish reason/i, 'unsupported_finish_reason'], + [/unsupported provider stream event/i, 'unsupported_stream_event'], + [/lossy conversion blocked/i, 'lossy_conversion_blocked'], + [/unaudited field/i, 'unaudited_field'], + [/invalid enum value/i, 'invalid_enum_value'], + [/invalid target field/i, 'invalid_target_field'], + [/unsupported field/i, 'unsupported_field'], + [/failed to parse .+ request/i, 'request_parse_failed'], + [/failed to emit .+ request/i, 'request_emit_failed'], + [/failed to parse .+ response/i, 'response_parse_failed'], + [/failed to emit .+ response/i, 'response_emit_failed'], + ] + code = patterns.find(([pattern]) => pattern.test(message))?.[1] || attempt.error_type || text(structured.kind) || 'unknown_failure' + } + const structuredPath = text(details.path) || text(structured.path) + const parsedPath = diagnosticPathFromMessage(message) + const reportedPath = normalizeFailurePath(structuredPath && structuredPath !== '$' ? structuredPath : parsedPath || text(payload.breakpoint)) + const streamFinishPaths: Record = { + 'claude:messages': '$.delta.stop_reason', + 'openai:chat': '$.choices[*].finish_reason', + 'gemini:generate_content': '$.candidates[*].finishReason', + } + const protocolPath = stage === 'stream' && /finish reason/i.test(message) + && (!structuredPath || structuredPath === '$') && ['$', '$.finish_reason'].includes(reportedPath) + ? streamFinishPaths[providerFormat] : undefined + const path = protocolPath ?? reportedPath + const missing = Array.isArray(details.missing_context) ? [...details.missing_context] : [] + if (path === '$' && !missing.includes('field_path')) missing.push('field_path') + if (stage === 'unknown') missing.push('failure_stage') + if (!clientFormat || !providerFormat) missing.push('api_formats') + let actual: unknown = details.actual ?? null + if (actual === null) { + const enumMatch = message.match(/invalid enum value\s+(.+?)\s+for\s+/i) + const fieldMatch = message.match(/\bfield\s+[^;=]+?\s*=\s*([^;]+)(?:;|$)/i) + const finishMatch = message.match(/(?:unsupported provider stream finish reason|upstream stream ended with finish reason):\s*(.+?)\s*$/i) + const literal = enumMatch?.[1] ?? fieldMatch?.[1] ?? finishMatch?.[1] + if (literal) { + try { actual = JSON.parse(literal) } catch { actual = literal } + } + if (code === 'stream_terminal_error' && /finish reason/.test(message)) actual = 'error' + } + return { + ...payload, + schema_version: 2, + breakpoint: path, + diagnostic: { + code, + stage, + stage_source: stageSource, + operation: details.operation ?? null, + reported_format: details.format ?? null, + reported_path: reportedPath, + path, + path_source: path === '$' ? 'unavailable' : structuredPath && structuredPath !== '$' ? 'structured' : protocolPath ? 'protocol_inference' : 'message_inference', + source_format: details.source_format ?? structured.source_format ?? conversionPair?.[1] ?? (stage === 'request' ? clientFormat : stage !== 'unknown' ? providerFormat : null), + target_format: details.target_format ?? structured.target_format ?? conversionPair?.[2] ?? (stage === 'request' ? providerFormat : stage !== 'unknown' ? clientFormat : null), + converter: structured.source ?? null, + expected: details.expected ?? details.reason ?? null, + actual, + missing_context: missing, + }, + versions: { + frontend: typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : null, + gateway_at_export: trace?.gateway_version ?? null, + runtime_at_failure: structured.runtime_version ?? null, + }, + node: { + ...diagnosticObject(payload.node), + candidate_id: attempt.id, + provider_id: attempt.provider_id ?? null, + endpoint_id: attempt.endpoint_id ?? null, + key_id: attempt.key_id ?? null, + }, + request: { + ...request, + client_api_format: clientFormat || null, + provider_api_format: providerFormat || null, + model: diagnosticContext.model ?? extra.model ?? null, + target_model: diagnosticContext.target_model ?? extra.target_model ?? null, + created_at: attempt.created_at, + started_at: attempt.started_at ?? null, + }, + reproduction: { status: 'not_loaded', sources: {}, missing_context: [...missing, 'source_body'] }, + } +} diff --git a/frontend/src/i18n/legacy-ui-messages.ts b/frontend/src/i18n/legacy-ui-messages.ts index 63efa2c70..44d4e02a9 100644 --- a/frontend/src/i18n/legacy-ui-messages.ts +++ b/frontend/src/i18n/legacy-ui-messages.ts @@ -1214,12 +1214,18 @@ export const legacyUiEnglishMessages: Record = { '未知失败': 'Unknown failure', '未知请求格式': 'Unknown request format', '未知上游格式': 'Unknown upstream format', + '正在读取并脱敏诊断上下文…': 'Loading and redacting diagnostic context…', + '复制时补取已采集的正文并脱敏;未采集、无权限或超限的信息会明确标注。分享前请检查诊断内容。': 'Copy loads and redacts captured bodies. Missing, forbidden, or oversized context is marked explicitly. Review the diagnostic before sharing.', + '上游流式响应异常终止:上游返回了错误结束原因(error),已按失败处理': 'The upstream stream ended with an error finish reason (error) and is marked as failed.', + '上游通过结束原因报告了流式错误:检查原始 SSE 中的 error/message 及上游日志;保持失败状态,不要映射为正常结束。': 'The upstream finish reason reports a streaming error. Inspect error/message in the raw SSE and upstream logs. Keep the request failed; do not map it to a normal completion.', + '断点在上游流式终态校验:检查原始结束原因及协议兼容性;该错误不代表发生了格式转换,不能确认成功时保持失败闭合。': 'The failure is in upstream stream terminal validation. Check the raw finish reason and protocol compatibility. This does not imply format conversion; keep the request failed unless success can be confirmed.', '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。': 'The failure is in upstream streaming event parsing or conversion. Check the event type at the breakpoint, then add a canonical mapping or a known no-op.', '断点在 finish_reason 映射:确认该结束原因是否可等价映射;不能无损映射时保持失败闭合。': 'The failure is in finish_reason mapping. Check whether an equivalent mapping exists. Keep the request failed if a lossless mapping is unavailable.', '无损': 'Lossless', '丢失信息': 'Information loss', '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。': 'The failure is in request or response conversion. Check whether the target format supports the breakpoint field. Otherwise, reject, downgrade, or define an explicit mapping.', '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。': 'Start replaying from the breakpoint field. If the breakpoint is $, inspect raw.failure_diagnostic, raw.error_message, and upstream_response first.', + '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / node.error_message 和 upstream_response。': 'Start replaying from the breakpoint field. If the breakpoint is $, inspect raw.failure_diagnostic, node.error_message, and upstream_response first.', '随机 (平分)': 'Random (equal weight)', '上游跳过': 'Upstream skipped', '未知 Key': 'Unknown key',