1#![allow(clippy::disallowed_methods, clippy::disallowed_types)]
70
71use std::{fs, io::Write, sync::LazyLock};
72
73use dashmap::DashMap;
74use serde::Deserialize;
75
76static REPORT_CURSORS: LazyLock<DashMap<String, u64>> = LazyLock::new(DashMap::new);
79
80#[derive(Debug, Deserialize)]
82#[serde(rename_all = "camelCase")]
83struct ProxyReport {
84 #[allow(dead_code)]
85 session_id: String,
86 #[allow(dead_code)]
87 agent_id: String,
88 #[allow(dead_code)]
89 project_path: Option<String>,
90 #[serde(default)]
91 user_name: Option<String>,
92 op_type: String,
93 #[allow(dead_code)]
94 method: Option<String>,
95 #[allow(dead_code)]
96 before_tokens: u64,
97 #[allow(dead_code)]
98 after_tokens: u64,
99 saved_tokens: u64,
100 #[allow(dead_code)]
101 #[serde(default)]
102 before_bytes: u64,
103 #[allow(dead_code)]
104 #[serde(default)]
105 after_bytes: u64,
106 #[allow(dead_code)]
107 #[serde(default)]
108 saved_bytes: u64,
109 #[allow(dead_code)]
110 #[serde(default)]
111 timestamp: String,
112}
113
114#[derive(Debug, Default)]
116pub(crate) struct TokenlessAccumulator {
117 pub total_saved: u64,
119 pub rtk_saved: u64,
121 pub response_saved: u64,
123 pub schema_saved: u64,
125 pub breakdown_json: String,
127 pub project_path: Option<String>,
129 pub user_name: Option<String>,
131}
132
133pub(crate) fn consume_report(session_id: &str) -> Option<TokenlessAccumulator> {
148 if session_id.is_empty() {
149 return None;
150 }
151
152 let safe_sid: String = session_id
154 .chars()
155 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
156 .take(128)
157 .collect();
158
159 if safe_sid.is_empty() {
160 return None;
161 }
162
163 let home = dirs::home_dir()?;
164 let reports_dir = home
165 .join(".tokenfleet-ai")
166 .join("tokenless")
167 .join("reports");
168 let source = reports_dir.join(format!("{safe_sid}.jsonl"));
169
170 let content = fs::read_to_string(&source).ok()?;
172 if content.is_empty() {
173 return None;
174 }
175
176 let mut cursor = REPORT_CURSORS.entry(safe_sid).or_insert(0u64);
178 let start_byte = usize::try_from(*cursor).unwrap_or(usize::MAX);
179
180 let start_byte = if start_byte > content.len() {
182 *cursor = 0;
183 0
184 } else {
185 start_byte
186 };
187
188 let mut acc = TokenlessAccumulator::default();
189 let mut breakdown_items: Vec<serde_json::Value> = Vec::new();
190 let mut has_new_lines = false;
191
192 for line in content.lines() {
193 let trimmed = line.trim();
194 if trimmed.is_empty() {
195 continue;
196 }
197
198 let line_start = line.as_ptr() as usize - content.as_ptr() as usize;
200 let is_new = line_start >= start_byte;
201
202 let Ok(report) = serde_json::from_str::<ProxyReport>(trimmed) else {
203 continue;
204 };
205
206 if acc.project_path.is_none() {
208 acc.project_path.clone_from(&report.project_path);
209 }
210 if acc.user_name.is_none() {
211 acc.user_name.clone_from(&report.user_name);
212 }
213
214 if is_new {
216 acc.total_saved += report.saved_tokens;
217 match report.op_type.as_str() {
218 "rewrite-command" => acc.rtk_saved += report.saved_tokens,
219 "compress-response" => acc.response_saved += report.saved_tokens,
220 "compress-schema" => acc.schema_saved += report.saved_tokens,
221 _ => {}
222 }
223 has_new_lines = true;
224
225 breakdown_items.push(serde_json::json!({
226 "op": report.op_type,
227 "method": report.method,
228 "beforeTokens": report.before_tokens,
229 "afterTokens": report.after_tokens,
230 "savedTokens": report.saved_tokens,
231 "beforeBytes": report.before_bytes,
232 "afterBytes": report.after_bytes,
233 "savedBytes": report.saved_bytes,
234 }));
235 }
236 }
237
238 if has_new_lines {
241 *cursor = content.len() as u64;
242 }
243
244 let has_new_data = has_new_lines;
245 let has_meta = acc.project_path.is_some() || acc.user_name.is_some();
246
247 if !has_new_data && !has_meta {
248 return None;
249 }
250
251 tracing::info!(
252 new_lines = breakdown_items.len(),
253 total_new_saved = acc.total_saved,
254 project_path = ?acc.project_path,
255 "consumed tokenless report (incremental)"
256 );
257
258 let _ = write_consume_log(
260 breakdown_items.len(),
261 acc.total_saved,
262 acc.project_path.as_deref(),
263 );
264
265 if !breakdown_items.is_empty() {
266 acc.breakdown_json = serde_json::to_string(&breakdown_items).unwrap_or_default();
267 }
268
269 Some(acc)
270}
271
272fn write_consume_log(lines: usize, total_saved: u64, project_path: Option<&str>) -> Result<(), ()> {
276 let home = dirs::home_dir().ok_or(())?;
277 let log_dir = home.join(".tokenfleet-ai").join("agent-proxy");
278 #[allow(clippy::disallowed_methods)]
279 std::fs::create_dir_all(&log_dir).map_err(|_| ())?;
280 let log_path = log_dir.join("report-consume.log");
281
282 let entry = serde_json::json!({
283 "ts": chrono::Utc::now().to_rfc3339(),
284 "lines": lines,
285 "total_saved": total_saved,
286 "project_path": project_path,
287 });
288
289 let mut f = std::fs::OpenOptions::new()
290 .create(true)
291 .append(true)
292 .open(&log_path)
293 .map_err(|_| ())?;
294
295 writeln!(f, "{}", serde_json::to_string(&entry).unwrap_or_default()).map_err(|_| ())
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used)]
300mod tests {
301 use super::*;
302
303 fn parse(content: &str, cursor: &mut u64) -> Option<TokenlessAccumulator> {
305 let start_byte = usize::try_from(*cursor).unwrap_or(usize::MAX);
306 let start = if start_byte > content.len() {
307 0
308 } else {
309 start_byte
310 };
311
312 let mut acc = TokenlessAccumulator::default();
313 let mut items: Vec<serde_json::Value> = Vec::new();
314 let mut has_new_lines = false;
315
316 for line in content.lines() {
317 let trimmed = line.trim();
318 if trimmed.is_empty() {
319 continue;
320 }
321 let line_start = line.as_ptr() as usize - content.as_ptr() as usize;
322 let is_new = line_start >= start;
323
324 let Ok(report) = serde_json::from_str::<super::ProxyReport>(trimmed) else {
325 continue;
326 };
327 if acc.project_path.is_none() {
328 acc.project_path.clone_from(&report.project_path);
329 }
330 if acc.user_name.is_none() {
331 acc.user_name.clone_from(&report.user_name);
332 }
333 if is_new {
334 acc.total_saved += report.saved_tokens;
335 match report.op_type.as_str() {
336 "rewrite-command" => acc.rtk_saved += report.saved_tokens,
337 "compress-response" => acc.response_saved += report.saved_tokens,
338 "compress-schema" => acc.schema_saved += report.saved_tokens,
339 _ => {}
340 }
341 has_new_lines = true;
342 items.push(serde_json::json!({
343 "op": report.op_type,
344 "savedTokens": report.saved_tokens,
345 }));
346 }
347 }
348 if has_new_lines {
349 *cursor = content.len() as u64;
350 }
351 if items.is_empty() && acc.project_path.is_none() && acc.user_name.is_none() {
352 return None;
353 }
354 if !items.is_empty() {
355 acc.breakdown_json = serde_json::to_string(&items).unwrap_or_default();
356 }
357 Some(acc)
358 }
359
360 #[test]
361 fn test_incremental_empty_returns_none() {
362 let mut cursor = 0u64;
363 assert!(parse("", &mut cursor).is_none());
364 }
365
366 #[test]
367 fn test_incremental_first_read() {
368 let jsonl = r#"{"sessionId":"s","agentId":"a","projectPath":"test-proj","userName":"byx","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":50,"beforeBytes":400,"afterBytes":200,"savedBytes":200}"#;
369 let mut cursor = 0u64;
370 let acc = parse(jsonl, &mut cursor).unwrap();
371 assert_eq!(acc.total_saved, 50);
372 assert_eq!(acc.rtk_saved, 50);
373 assert_eq!(acc.response_saved, 0);
374 assert_eq!(acc.schema_saved, 0);
375 assert_eq!(acc.project_path.as_deref(), Some("test-proj"));
376 assert_eq!(acc.user_name.as_deref(), Some("byx"));
377 assert!(cursor > 0);
378 }
379
380 #[test]
381 fn test_per_category_splitting() {
382 let lines = [
383 r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":30,"beforeBytes":100,"afterBytes":50,"savedBytes":50}"#,
384 r#"{"sessionId":"s","agentId":"a","opType":"rewrite-command","method":"Rtk","beforeTokens":50,"afterTokens":20,"savedTokens":25,"beforeBytes":200,"afterBytes":80,"savedBytes":120}"#,
385 r#"{"sessionId":"s","agentId":"a","opType":"compress-response","method":"Standard","beforeTokens":500,"afterTokens":300,"savedTokens":200,"beforeBytes":2000,"afterBytes":1200,"savedBytes":800}"#,
386 r#"{"sessionId":"s","agentId":"a","opType":"compress-schema","method":"ToonHrv","beforeTokens":1000,"afterTokens":700,"savedTokens":300,"beforeBytes":4000,"afterBytes":2800,"savedBytes":1200}"#,
387 ];
388 let content = lines.join("\n");
389 let mut cursor = 0u64;
390 let acc = parse(&content, &mut cursor).unwrap();
391 assert_eq!(acc.total_saved, 30 + 25 + 200 + 300);
392 assert_eq!(acc.rtk_saved, 30 + 25);
393 assert_eq!(acc.response_saved, 200);
394 assert_eq!(acc.schema_saved, 300);
395 assert_eq!(acc.project_path.as_deref(), Some("p"));
396 assert_eq!(acc.user_name.as_deref(), Some("u"));
397 }
398
399 #[test]
400 fn test_incremental_second_read_only_new_lines() {
401 let first = r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":50,"beforeBytes":400,"afterBytes":200,"savedBytes":200}"#;
402 let mut cursor = 0u64;
403 let acc1 = parse(first, &mut cursor).unwrap();
405 assert_eq!(acc1.total_saved, 50);
406
407 let second_line = r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":200,"afterTokens":100,"savedTokens":100,"beforeBytes":800,"afterBytes":400,"savedBytes":400}"#;
409 let full = format!("{first}\n{second_line}\n");
410 let acc2 = parse(&full, &mut cursor).unwrap();
411 assert_eq!(acc2.total_saved, 100);
413 assert_eq!(acc2.project_path.as_deref(), Some("p"));
415 assert_eq!(acc2.user_name.as_deref(), Some("u"));
416 }
417
418 #[test]
419 fn test_incremental_no_new_data_returns_meta() {
420 let first = r#"{"sessionId":"s","agentId":"a","projectPath":"p","userName":"u","opType":"rewrite-command","method":"Rtk","beforeTokens":100,"afterTokens":50,"savedTokens":50,"beforeBytes":400,"afterBytes":200,"savedBytes":200}"#;
421 let mut cursor = 0u64;
422 parse(first, &mut cursor);
423
424 let acc = parse(first, &mut cursor).unwrap();
426 assert_eq!(acc.total_saved, 0);
427 assert_eq!(acc.project_path.as_deref(), Some("p"));
428 assert_eq!(acc.user_name.as_deref(), Some("u"));
429 assert!(acc.breakdown_json.is_empty());
430 }
431}