1use std::fs;
9use std::path::PathBuf;
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::config::data_dir;
17
18const ETU_WEIGHT_INPUT: f64 = 1.0;
21const ETU_WEIGHT_CACHE_READ: f64 = 0.1;
23const ETU_WEIGHT_CACHE_WRITE: f64 = 1.25;
25const ETU_WEIGHT_OUTPUT: f64 = 5.0;
27
28#[allow(clippy::cast_precision_loss)]
35pub(crate) fn compute_etu(input: u64, cache_read: u64, cache_write: u64, output: u64) -> f64 {
36 ETU_WEIGHT_INPUT * input as f64
37 + ETU_WEIGHT_CACHE_READ * cache_read as f64
38 + ETU_WEIGHT_CACHE_WRITE * cache_write as f64
39 + ETU_WEIGHT_OUTPUT * output as f64
40}
41
42#[derive(Debug, Clone, Default, Serialize, PartialEq)]
44pub struct AiStats {
45 pub provider: String,
47 pub model: String,
49 pub input_tokens: u64,
51 pub output_tokens: u64,
53 pub duration_ms: u64,
55 #[serde(default)]
57 pub cost_usd: Option<f64>,
58 #[serde(default)]
60 pub fallback_provider: Option<String>,
61 #[serde(default)]
63 pub prompt_chars: usize,
64 #[serde(default)]
66 pub cache_read_tokens: u64,
67 #[serde(default)]
69 pub cache_write_tokens: u64,
70 #[serde(default)]
73 pub effective_token_units: f64,
74 #[serde(default)]
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub trace_id: Option<String>,
78}
79
80impl AiStats {
81 #[must_use]
86 pub fn with_computed_etu(mut self) -> Self {
87 self.effective_token_units = compute_etu(
88 self.input_tokens,
89 self.cache_read_tokens,
90 self.cache_write_tokens,
91 self.output_tokens,
92 );
93 self
94 }
95}
96
97impl<'de> Deserialize<'de> for AiStats {
98 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
99 where
100 D: serde::Deserializer<'de>,
101 {
102 #[derive(Deserialize)]
103 struct Helper {
104 #[serde(default)]
105 provider: String,
106 #[serde(default)]
107 model: String,
108 #[serde(default)]
109 input_tokens: u64,
110 #[serde(default)]
111 output_tokens: u64,
112 #[serde(default)]
113 duration_ms: u64,
114 #[serde(default)]
115 cost_usd: Option<f64>,
116 #[serde(default)]
117 fallback_provider: Option<String>,
118 #[serde(default)]
119 prompt_chars: usize,
120 #[serde(default)]
121 cache_read_tokens: u64,
122 #[serde(default)]
123 cache_write_tokens: u64,
124 #[serde(default)]
126 #[allow(dead_code)]
127 effective_token_units: f64,
128 #[serde(default)]
129 trace_id: Option<String>,
130 }
131
132 let h = Helper::deserialize(deserializer)?;
133 Ok(AiStats {
134 provider: h.provider,
135 model: h.model,
136 input_tokens: h.input_tokens,
137 output_tokens: h.output_tokens,
138 duration_ms: h.duration_ms,
139 cost_usd: h.cost_usd,
140 fallback_provider: h.fallback_provider,
141 prompt_chars: h.prompt_chars,
142 cache_read_tokens: h.cache_read_tokens,
143 cache_write_tokens: h.cache_write_tokens,
144 effective_token_units: 0.0,
145 trace_id: h.trace_id,
146 }
147 .with_computed_etu())
148 }
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "lowercase")]
154pub enum ContributionStatus {
155 #[default]
157 Pending,
158 Accepted,
160 Rejected,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Contribution {
167 pub id: Uuid,
169 pub repo: String,
171 pub issue: u64,
173 pub action: String,
175 pub timestamp: DateTime<Utc>,
177 pub comment_url: String,
179 #[serde(default)]
181 pub status: ContributionStatus,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub ai_stats: Option<AiStats>,
185}
186
187#[derive(Debug, Default, Serialize, Deserialize)]
189pub struct HistoryData {
190 pub contributions: Vec<Contribution>,
192}
193
194impl HistoryData {
195 #[must_use]
197 pub fn total_tokens(&self) -> u64 {
198 self.contributions
199 .iter()
200 .filter_map(|c| c.ai_stats.as_ref())
201 .map(|stats| stats.input_tokens + stats.output_tokens)
202 .sum()
203 }
204
205 #[must_use]
207 pub fn total_cost(&self) -> f64 {
208 self.contributions
209 .iter()
210 .filter_map(|c| c.ai_stats.as_ref())
211 .filter_map(|stats| stats.cost_usd)
212 .sum()
213 }
214
215 #[must_use]
217 #[allow(clippy::cast_precision_loss)]
218 pub fn avg_tokens_per_triage(&self) -> f64 {
219 let contributions_with_stats: Vec<_> = self
220 .contributions
221 .iter()
222 .filter_map(|c| c.ai_stats.as_ref())
223 .collect();
224
225 if contributions_with_stats.is_empty() {
226 return 0.0;
227 }
228
229 let total: u64 = contributions_with_stats
230 .iter()
231 .map(|stats| stats.input_tokens + stats.output_tokens)
232 .sum();
233
234 total as f64 / contributions_with_stats.len() as f64
235 }
236
237 #[must_use]
239 pub fn cost_by_model(&self) -> std::collections::HashMap<String, f64> {
240 let mut costs = std::collections::HashMap::new();
241
242 for contribution in &self.contributions {
243 if let Some(stats) = &contribution.ai_stats
244 && let Some(cost) = stats.cost_usd
245 {
246 *costs.entry(stats.model.clone()).or_insert(0.0) += cost;
247 }
248 }
249
250 costs
251 }
252}
253
254#[must_use]
256pub fn history_file_path() -> PathBuf {
257 data_dir().join("history.json")
258}
259
260pub fn load() -> Result<HistoryData> {
264 let path = history_file_path();
265
266 if !path.exists() {
267 return Ok(HistoryData::default());
268 }
269
270 let contents = fs::read_to_string(&path)
271 .with_context(|| format!("Failed to read history file: {}", path.display()))?;
272
273 let data: HistoryData = serde_json::from_str(&contents)
274 .with_context(|| format!("Failed to parse history file: {}", path.display()))?;
275
276 Ok(data)
277}
278
279pub fn save(data: &HistoryData) -> Result<()> {
283 let path = history_file_path();
284
285 if let Some(parent) = path.parent() {
287 fs::create_dir_all(parent)
288 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
289 }
290
291 let contents =
292 serde_json::to_string_pretty(data).context("Failed to serialize history data")?;
293
294 fs::write(&path, contents)
295 .with_context(|| format!("Failed to write history file: {}", path.display()))?;
296
297 Ok(())
298}
299
300pub fn add_contribution(contribution: Contribution) -> Result<()> {
304 let mut data = load()?;
305 data.contributions.push(contribution);
306 save(&data)?;
307 Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn test_contribution() -> Contribution {
316 Contribution {
317 id: Uuid::new_v4(),
318 repo: "owner/repo".to_string(),
319 issue: 123,
320 action: "triage".to_string(),
321 timestamp: Utc::now(),
322 comment_url: "https://github.com/owner/repo/issues/123#issuecomment-1".to_string(),
323 status: ContributionStatus::Pending,
324 ai_stats: None,
325 }
326 }
327
328 #[test]
329 fn test_contribution_serialization_roundtrip() {
330 let contribution = test_contribution();
331 let json = serde_json::to_string(&contribution).expect("serialize");
332 let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
333
334 assert_eq!(contribution.id, parsed.id);
335 assert_eq!(contribution.repo, parsed.repo);
336 assert_eq!(contribution.issue, parsed.issue);
337 assert_eq!(contribution.action, parsed.action);
338 assert_eq!(contribution.comment_url, parsed.comment_url);
339 assert_eq!(contribution.status, parsed.status);
340 }
341
342 #[test]
343 fn test_history_data_serialization_roundtrip() {
344 let data = HistoryData {
345 contributions: vec![test_contribution(), test_contribution()],
346 };
347
348 let json = serde_json::to_string_pretty(&data).expect("serialize");
349 let parsed: HistoryData = serde_json::from_str(&json).expect("deserialize");
350
351 assert_eq!(parsed.contributions.len(), 2);
352 }
353
354 #[test]
355 fn test_contribution_status_default() {
356 let status = ContributionStatus::default();
357 assert_eq!(status, ContributionStatus::Pending);
358 }
359
360 #[test]
361 fn test_contribution_status_serialization() {
362 assert_eq!(
363 serde_json::to_string(&ContributionStatus::Pending).unwrap(),
364 "\"pending\""
365 );
366 assert_eq!(
367 serde_json::to_string(&ContributionStatus::Accepted).unwrap(),
368 "\"accepted\""
369 );
370 assert_eq!(
371 serde_json::to_string(&ContributionStatus::Rejected).unwrap(),
372 "\"rejected\""
373 );
374 }
375
376 #[test]
377 fn test_empty_history_default() {
378 let data = HistoryData::default();
379 assert!(data.contributions.is_empty());
380 }
381
382 #[test]
383 fn test_ai_stats_serialization_roundtrip() {
384 let stats = AiStats {
385 provider: "openrouter".to_string(),
386 model: "mistralai/mistral-small-2603".to_string(),
387 input_tokens: 1000,
388 output_tokens: 500,
389 duration_ms: 1500,
390 cost_usd: Some(0.0),
391 fallback_provider: None,
392 prompt_chars: 0,
393 cache_read_tokens: 0,
394 cache_write_tokens: 0,
395 effective_token_units: 0.0,
396 trace_id: None,
397 };
398
399 let json = serde_json::to_string(&stats).expect("serialize");
400 let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
401
402 assert_eq!(stats.provider, parsed.provider);
405 assert_eq!(stats.model, parsed.model);
406 assert_eq!(stats.input_tokens, parsed.input_tokens);
407 assert_eq!(stats.output_tokens, parsed.output_tokens);
408 assert_eq!(stats.duration_ms, parsed.duration_ms);
409 assert_eq!(stats.cost_usd, parsed.cost_usd);
410 assert_eq!(stats.fallback_provider, parsed.fallback_provider);
411 assert_eq!(stats.prompt_chars, parsed.prompt_chars);
412 assert_eq!(stats.cache_read_tokens, parsed.cache_read_tokens);
413 assert_eq!(stats.cache_write_tokens, parsed.cache_write_tokens);
414 assert_eq!(stats.trace_id, parsed.trace_id);
415 assert!((parsed.effective_token_units - 3500.0).abs() < f64::EPSILON);
417 }
418
419 #[test]
420 fn test_contribution_with_ai_stats() {
421 let mut contribution = test_contribution();
422 contribution.ai_stats = Some(AiStats {
423 provider: "openrouter".to_string(),
424 model: "mistralai/mistral-small-2603".to_string(),
425 input_tokens: 1000,
426 output_tokens: 500,
427 duration_ms: 1500,
428 cost_usd: Some(0.0),
429 fallback_provider: None,
430 prompt_chars: 0,
431 cache_read_tokens: 0,
432 cache_write_tokens: 0,
433 effective_token_units: 0.0,
434 trace_id: None,
435 });
436
437 let json = serde_json::to_string(&contribution).expect("serialize");
438 let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
439
440 assert!(parsed.ai_stats.is_some());
441 assert_eq!(
442 parsed.ai_stats.unwrap().model,
443 "mistralai/mistral-small-2603"
444 );
445 }
446
447 #[test]
448 fn test_contribution_without_ai_stats_backward_compat() {
449 let json = r#"{
450 "id": "550e8400-e29b-41d4-a716-446655440000",
451 "repo": "owner/repo",
452 "issue": 123,
453 "action": "triage",
454 "timestamp": "2024-01-01T00:00:00Z",
455 "comment_url": "https://github.com/owner/repo/issues/123#issuecomment-1",
456 "status": "pending"
457 }"#;
458
459 let parsed: Contribution = serde_json::from_str(json).expect("deserialize");
460 assert!(parsed.ai_stats.is_none());
461 }
462
463 #[test]
464 fn test_total_tokens() {
465 let mut data = HistoryData::default();
466
467 let mut c1 = test_contribution();
468 c1.ai_stats = Some(AiStats {
469 provider: "openrouter".to_string(),
470 model: "model1".to_string(),
471 input_tokens: 100,
472 output_tokens: 50,
473 duration_ms: 1000,
474 cost_usd: Some(0.01),
475 fallback_provider: None,
476 prompt_chars: 0,
477 cache_read_tokens: 0,
478 cache_write_tokens: 0,
479 effective_token_units: 0.0,
480 trace_id: None,
481 });
482
483 let mut c2 = test_contribution();
484 c2.ai_stats = Some(AiStats {
485 provider: "openrouter".to_string(),
486 model: "model2".to_string(),
487 input_tokens: 200,
488 output_tokens: 100,
489 duration_ms: 2000,
490 cost_usd: Some(0.02),
491 fallback_provider: None,
492 prompt_chars: 0,
493 cache_read_tokens: 0,
494 cache_write_tokens: 0,
495 effective_token_units: 0.0,
496 trace_id: None,
497 });
498
499 data.contributions.push(c1);
500 data.contributions.push(c2);
501 data.contributions.push(test_contribution()); assert_eq!(data.total_tokens(), 450);
504 }
505
506 #[test]
507 fn test_total_cost() {
508 let mut data = HistoryData::default();
509
510 let mut c1 = test_contribution();
511 c1.ai_stats = Some(AiStats {
512 provider: "openrouter".to_string(),
513 model: "model1".to_string(),
514 input_tokens: 100,
515 output_tokens: 50,
516 duration_ms: 1000,
517 cost_usd: Some(0.01),
518 fallback_provider: None,
519 prompt_chars: 0,
520 cache_read_tokens: 0,
521 cache_write_tokens: 0,
522 effective_token_units: 0.0,
523 trace_id: None,
524 });
525
526 let mut c2 = test_contribution();
527 c2.ai_stats = Some(AiStats {
528 provider: "openrouter".to_string(),
529 model: "model2".to_string(),
530 input_tokens: 200,
531 output_tokens: 100,
532 duration_ms: 2000,
533 cost_usd: Some(0.02),
534 fallback_provider: None,
535 prompt_chars: 0,
536 cache_read_tokens: 0,
537 cache_write_tokens: 0,
538 effective_token_units: 0.0,
539 trace_id: None,
540 });
541
542 data.contributions.push(c1);
543 data.contributions.push(c2);
544
545 assert!((data.total_cost() - 0.03).abs() < f64::EPSILON);
546 }
547
548 #[test]
549 fn test_avg_tokens_per_triage() {
550 let mut data = HistoryData::default();
551
552 let mut c1 = test_contribution();
553 c1.ai_stats = Some(AiStats {
554 provider: "openrouter".to_string(),
555 model: "model1".to_string(),
556 input_tokens: 100,
557 output_tokens: 50,
558 duration_ms: 1000,
559 cost_usd: Some(0.01),
560 fallback_provider: None,
561 prompt_chars: 0,
562 cache_read_tokens: 0,
563 cache_write_tokens: 0,
564 effective_token_units: 0.0,
565 trace_id: None,
566 });
567
568 let mut c2 = test_contribution();
569 c2.ai_stats = Some(AiStats {
570 provider: "openrouter".to_string(),
571 model: "model2".to_string(),
572 input_tokens: 200,
573 output_tokens: 100,
574 duration_ms: 2000,
575 cost_usd: Some(0.02),
576 fallback_provider: None,
577 prompt_chars: 0,
578 cache_read_tokens: 0,
579 cache_write_tokens: 0,
580 effective_token_units: 0.0,
581 trace_id: None,
582 });
583
584 data.contributions.push(c1);
585 data.contributions.push(c2);
586
587 assert!((data.avg_tokens_per_triage() - 225.0).abs() < f64::EPSILON);
588 }
589
590 #[test]
591 fn test_avg_tokens_per_triage_empty() {
592 let data = HistoryData::default();
593 assert!((data.avg_tokens_per_triage() - 0.0).abs() < f64::EPSILON);
594 }
595
596 #[test]
597 fn test_cost_by_model() {
598 let mut data = HistoryData::default();
599
600 let mut c1 = test_contribution();
601 c1.ai_stats = Some(AiStats {
602 provider: "openrouter".to_string(),
603 model: "model1".to_string(),
604 input_tokens: 100,
605 output_tokens: 50,
606 duration_ms: 1000,
607 cost_usd: Some(0.01),
608 fallback_provider: None,
609 prompt_chars: 0,
610 cache_read_tokens: 0,
611 cache_write_tokens: 0,
612 effective_token_units: 0.0,
613 trace_id: None,
614 });
615
616 let mut c2 = test_contribution();
617 c2.ai_stats = Some(AiStats {
618 provider: "openrouter".to_string(),
619 model: "model1".to_string(),
620 input_tokens: 200,
621 output_tokens: 100,
622 duration_ms: 2000,
623 cost_usd: Some(0.02),
624 fallback_provider: None,
625 prompt_chars: 0,
626 cache_read_tokens: 0,
627 cache_write_tokens: 0,
628 effective_token_units: 0.0,
629 trace_id: None,
630 });
631
632 let mut c3 = test_contribution();
633 c3.ai_stats = Some(AiStats {
634 provider: "openrouter".to_string(),
635 model: "model2".to_string(),
636 input_tokens: 150,
637 output_tokens: 75,
638 duration_ms: 1500,
639 cost_usd: Some(0.015),
640 fallback_provider: None,
641 prompt_chars: 0,
642 cache_read_tokens: 0,
643 cache_write_tokens: 0,
644 effective_token_units: 0.0,
645 trace_id: None,
646 });
647
648 data.contributions.push(c1);
649 data.contributions.push(c2);
650 data.contributions.push(c3);
651
652 let costs = data.cost_by_model();
653 assert_eq!(costs.len(), 2);
654 assert!((costs.get("model1").unwrap() - 0.03).abs() < f64::EPSILON);
655 assert!((costs.get("model2").unwrap() - 0.015).abs() < f64::EPSILON);
656 }
657
658 #[test]
659 fn test_ai_stats_cache_tokens_roundtrip() {
660 let stats = AiStats {
661 provider: "anthropic".to_string(),
662 model: "claude-sonnet-4-6".to_string(),
663 input_tokens: 1000,
664 output_tokens: 500,
665 duration_ms: 1500,
666 cost_usd: Some(0.05),
667 fallback_provider: None,
668 prompt_chars: 5000,
669 cache_read_tokens: 100,
670 cache_write_tokens: 50,
671 effective_token_units: 0.0,
672 trace_id: None,
673 };
674
675 let json = serde_json::to_string(&stats).expect("serialize");
676 let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
677
678 assert_eq!(stats.provider, parsed.provider);
681 assert_eq!(stats.model, parsed.model);
682 assert_eq!(stats.input_tokens, parsed.input_tokens);
683 assert_eq!(stats.output_tokens, parsed.output_tokens);
684 assert_eq!(stats.duration_ms, parsed.duration_ms);
685 assert_eq!(stats.cost_usd, parsed.cost_usd);
686 assert_eq!(stats.fallback_provider, parsed.fallback_provider);
687 assert_eq!(stats.prompt_chars, parsed.prompt_chars);
688 assert_eq!(stats.cache_read_tokens, 100);
689 assert_eq!(stats.cache_write_tokens, 50);
690 assert_eq!(parsed.cache_read_tokens, 100);
691 assert_eq!(parsed.cache_write_tokens, 50);
692 assert!((parsed.effective_token_units - 3572.5).abs() < f64::EPSILON);
694 }
695
696 #[test]
697 fn test_ai_stats_cache_tokens_default() {
698 let json = r#"{
699 "provider": "openrouter",
700 "model": "mistralai/mistral-small-2603",
701 "input_tokens": 1000,
702 "output_tokens": 500,
703 "duration_ms": 1500,
704 "cost_usd": 0.0,
705 "fallback_provider": null,
706 "prompt_chars": 0
707 }"#;
708
709 let parsed: AiStats = serde_json::from_str(json).expect("deserialize");
710
711 assert_eq!(parsed.cache_read_tokens, 0);
712 assert_eq!(parsed.cache_write_tokens, 0);
713 }
714
715 #[test]
716 fn test_etu_formula() {
717 let stats = AiStats {
720 input_tokens: 1000,
721 output_tokens: 200,
722 cache_read_tokens: 500,
723 cache_write_tokens: 100,
724 ..AiStats::default()
725 }
726 .with_computed_etu();
727 assert!((stats.effective_token_units - 2175.0).abs() < f64::EPSILON);
728 }
729
730 #[test]
731 fn test_etu_zero_on_default() {
732 let stats = AiStats::default().with_computed_etu();
734 assert_eq!(stats.effective_token_units, 0.0);
735 }
736
737 #[test]
738 fn test_etu_recomputed_on_deserialize() {
739 let json = r#"{
742 "provider": "anthropic",
743 "model": "claude-sonnet-4-6",
744 "input_tokens": 1000,
745 "output_tokens": 200,
746 "cache_read_tokens": 500,
747 "cache_write_tokens": 100,
748 "effective_token_units": 99999.0
749 }"#;
750 let stats: AiStats = serde_json::from_str(json).unwrap();
751 assert!((stats.effective_token_units - 2175.0).abs() < f64::EPSILON);
753 }
754}