From baf9f7fdb7f583cbe76c3787af620ea502b605d5 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Tue, 2 Jun 2026 20:01:15 +0800 Subject: [PATCH] fix(sight): show non-streaming LLM calls in audit audit --type llm only showed SSE (streaming) calls: analyze_http returned None for non-SSE requests, so non-streaming completions (stream:false) were invisible. Create an llm_call audit record for non-streaming LLM API calls too (matched by path: /chat/completions, /v1/messages, /v1/completions, /api/v1/copilot/generate_copilot), and set is_sse from the record instead of hardcoding true. Non-streaming token extraction is handled separately by #789; merge that first so these records carry token counts. --- src/agentsight/src/analyzer/audit/analyzer.rs | 82 ++++++++++++++++++- src/agentsight/src/bin/cli/summary.rs | 2 +- src/agentsight/src/server/token_savings.rs | 22 ++--- 3 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/agentsight/src/analyzer/audit/analyzer.rs b/src/agentsight/src/analyzer/audit/analyzer.rs index 070cb144a..b6e9fa5c3 100644 --- a/src/agentsight/src/analyzer/audit/analyzer.rs +++ b/src/agentsight/src/analyzer/audit/analyzer.rs @@ -47,8 +47,16 @@ impl AuditAnalyzer { http_record: &HttpRecord, token_record: Option<&TokenRecord>, ) -> Option { - // Only create llm_call for SSE responses - if !http_record.is_sse { + // Create llm_call audit records for SSE responses AND non-streaming + // LLM API calls (identified by path). Without this, non-streaming + // completions (stream:false) are invisible in audit --type llm. + let is_llm_path = http_record.path.contains("/chat/completions") + || http_record.path.contains("/v1/messages") + || http_record.path.contains("/v1/completions") + || http_record + .path + .contains("/api/v1/copilot/generate_copilot"); + if !http_record.is_sse && !is_llm_path { return None; } @@ -88,7 +96,7 @@ impl AuditAnalyzer { output_tokens, cache_creation_tokens, cache_read_tokens, - is_sse: true, + is_sse: http_record.is_sse, }, session_id: None, }) @@ -181,6 +189,7 @@ fn detect_model_from_request(pair: &HttpPair) -> Option { mod tests { use super::*; use crate::aggregator::AggregatedProcess; + use crate::analyzer::token::TokenRecord; #[test] fn test_extract_process_action_propagates_session_id() { @@ -208,4 +217,71 @@ mod tests { assert_eq!(record.session_id, None); } + + fn make_http_record(path: &str, is_sse: bool, response_body: Option<&str>) -> HttpRecord { + HttpRecord { + timestamp_ns: 0, + pid: 1, + comm: "test".into(), + method: "POST".into(), + path: path.into(), + status_code: 200, + request_headers: "{}".into(), + request_body: Some(r#"{"model":"test-model"}"#.into()), + response_headers: "{}".into(), + response_body: response_body.map(|s| s.to_string()), + duration_ns: 1000, + is_sse, + sse_event_count: 0, + } + } + + #[test] + fn test_nonsse_llm_path_produces_audit() { + let analyzer = AuditAnalyzer::new(); + let record = make_http_record("/v1/chat/completions", false, None); + let token = TokenRecord::new(1, "test".into(), "openai".into(), 100, 20); + let result = analyzer.analyze_http(&record, Some(&token)); + assert!( + result.is_some(), + "non-SSE LLM path must produce audit record" + ); + let audit = result.unwrap(); + if let AuditExtra::LlmCall { + is_sse, + input_tokens, + output_tokens, + .. + } = &audit.extra + { + assert!(!is_sse, "non-SSE call must have is_sse=false"); + assert_eq!(*input_tokens, 100); + assert_eq!(*output_tokens, 20); + } else { + panic!("expected LlmCall extra"); + } + } + + #[test] + fn test_nonsse_nonllm_path_no_audit() { + let analyzer = AuditAnalyzer::new(); + let record = make_http_record("/api/health", false, None); + let result = analyzer.analyze_http(&record, None); + assert!( + result.is_none(), + "non-LLM path must NOT produce audit record" + ); + } + + #[test] + fn test_sse_path_produces_audit_with_sse_true() { + let analyzer = AuditAnalyzer::new(); + let record = make_http_record("/v1/chat/completions", true, None); + let token = TokenRecord::new(1, "test".into(), "openai".into(), 50, 10); + let result = analyzer.analyze_http(&record, Some(&token)); + assert!(result.is_some()); + if let AuditExtra::LlmCall { is_sse, .. } = &result.unwrap().extra { + assert!(*is_sse, "SSE call must have is_sse=true"); + } + } } diff --git a/src/agentsight/src/bin/cli/summary.rs b/src/agentsight/src/bin/cli/summary.rs index 8e4e0dfab..9290736c2 100644 --- a/src/agentsight/src/bin/cli/summary.rs +++ b/src/agentsight/src/bin/cli/summary.rs @@ -188,7 +188,7 @@ fn gather_sessions(path: &Path, start_ns: i64, end_ns: i64) -> SessionStats { return SessionStats::default(); } }; - let sessions = match store.list_sessions(start_ns, end_ns) { + let sessions = match store.list_sessions(start_ns, end_ns, true) { Ok(s) => s, Err(e) => { eprintln!("warning: cannot query sessions: {e}"); diff --git a/src/agentsight/src/server/token_savings.rs b/src/agentsight/src/server/token_savings.rs index 896366905..2a2d3230d 100644 --- a/src/agentsight/src/server/token_savings.rs +++ b/src/agentsight/src/server/token_savings.rs @@ -171,22 +171,18 @@ fn generate_optimization_reason(operation: &str, before_tokens: i64, after_token }; match operation { "compress-response" => format!( - "MCP 服务器返回的响应内容经过压缩处理,移除冗余字段和重复信息,节省 {}%({} tokens)", - pct, saved + "MCP 服务器返回的响应内容经过压缩处理,移除冗余字段和重复信息,节省 {pct}%({saved} tokens)" ), "rewrite-command" => format!( - "工具调用的输出内容经过精简重写,保留关键语义同时降低 token 开销,节省 {}%({} tokens)", - pct, saved + "工具调用的输出内容经过精简重写,保留关键语义同时降低 token 开销,节省 {pct}%({saved} tokens)" ), "compress-schema" => format!( - "工具的 JSON Schema 定义经过压缩,移除描述性文本和可选字段,节省 {}%({} tokens)", - pct, saved + "工具的 JSON Schema 定义经过压缩,移除描述性文本和可选字段,节省 {pct}%({saved} tokens)" ), "compress-toon" => format!( - "使用 TOON 结构化编码替代原始 JSON,大幅缩减 token 占用,节省 {}%({} tokens)", - pct, saved + "使用 TOON 结构化编码替代原始 JSON,大幅缩减 token 占用,节省 {pct}%({saved} tokens)" ), - _ => format!("内容经过优化处理,节省 {}%({} tokens)", pct, saved), + _ => format!("内容经过优化处理,节省 {pct}%({saved} tokens)"), } } @@ -1139,11 +1135,11 @@ mod tests { #[test] fn test_diff_large_input_fallback() { let big_before = (0..1200) - .map(|i| format!("line{}", i)) + .map(|i| format!("line{i}")) .collect::>() .join("\n"); let big_after = (0..1000) - .map(|i| format!("new{}", i)) + .map(|i| format!("new{i}")) .collect::>() .join("\n"); let result = compute_diff_lines(Some(&big_before), Some(&big_after)); @@ -1163,11 +1159,11 @@ mod tests { fn test_diff_max_lines_cap() { // Create input that would generate many diff lines let before = (0..150) - .map(|i| format!("old_{}", i)) + .map(|i| format!("old_{i}")) .collect::>() .join("\n"); let after = (0..150) - .map(|i| format!("new_{}", i)) + .map(|i| format!("new_{i}")) .collect::>() .join("\n"); let result = compute_diff_lines(Some(&before), Some(&after));