use super::utils::parse_timestamp_value;
use super::UnifiedMessage;
use crate::provider_identity::{canonical_provider, inferred_provider_from_model};
use crate::TokenBreakdown;
use serde::Deserialize;
use std::io::{BufRead, BufReader};
use std::path::Path;
const CLIENT_ID: &str = "reasonix";
const UNKNOWN_MODEL: &str = "reasonix-unknown";
#[derive(Debug, Deserialize)]
struct ReasonixStat {
ts: serde_json::Value,
#[serde(default)]
model: String,
#[serde(default)]
prompt: i64,
#[serde(default)]
completion: i64,
#[serde(default)]
reasoning: i64,
#[serde(default)]
cache_hit: i64,
cache_miss: Option<i64>,
#[serde(default)]
total: i64,
#[serde(default)]
requests: i64,
#[serde(default)]
turn: bool,
}
fn split_model_ref(model_ref: &str) -> (String, String) {
let model_ref = model_ref.trim();
if let Some((provider, model)) = model_ref.split_once('/') {
let provider = canonical_provider(provider).unwrap_or_else(|| provider.to_string());
return (provider, model.trim().to_string());
}
let provider = inferred_provider_from_model(model_ref)
.unwrap_or(CLIENT_ID)
.to_string();
(provider, model_ref.to_string())
}
fn non_negative(value: i64) -> i64 {
value.max(0)
}
pub fn parse_reasonix_file(path: &Path) -> Vec<UnifiedMessage> {
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let source_id = format!("reasonix-stats:{}", path.display());
BufReader::new(file)
.lines()
.enumerate()
.filter_map(|(line_index, line)| {
let line = line.ok()?;
let record: ReasonixStat = serde_json::from_str(line.trim()).ok()?;
if record.turn
|| record.model.trim().is_empty()
|| (record.total <= 0 && record.requests <= 0)
{
return None;
}
let timestamp = parse_timestamp_value(&record.ts)?;
let (provider_id, model_id) = split_model_ref(&record.model);
let cache_read = non_negative(record.cache_hit);
let raw_prompt = non_negative(record.prompt);
let input = match record.cache_miss {
Some(cache_miss) if cache_miss != 0 => non_negative(cache_miss),
_ => raw_prompt.saturating_sub(cache_read),
};
let reasoning = non_negative(record.reasoning).min(non_negative(record.completion));
let output = non_negative(record.completion).saturating_sub(reasoning);
let tokens = TokenBreakdown {
input,
output,
cache_read,
cache_write: 0,
reasoning,
};
let dedup_key = format!(
"reasonix:{source_id}:{line_index}:{requests}:{total}",
requests = record.requests,
total = record.total,
);
let mut message = UnifiedMessage::new_with_dedup(
CLIENT_ID,
if model_id.is_empty() {
UNKNOWN_MODEL.to_string()
} else {
model_id
},
provider_id,
source_id.clone(),
timestamp,
tokens,
0.0,
Some(dedup_key),
);
message.message_count = record.requests.clamp(1, i64::from(i32::MAX)) as i32;
Some(message)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
fn write_stats(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
use std::io::Write;
file.write_all(content.as_bytes()).unwrap();
file.flush().unwrap();
file
}
#[test]
fn parses_authoritative_stats_with_provider_usage_and_timestamp() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:10:11Z","model":"deepseek/chat","prompt":100,"completion":20,"cache_hit":10,"total":130,"requests":3}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
let message = &messages[0];
assert_eq!(message.client, CLIENT_ID);
assert_eq!(message.provider_id, "deepseek");
assert_eq!(message.model_id, "chat");
assert_eq!(message.timestamp, 1_785_834_611_000);
assert_eq!(message.tokens.input, 90);
assert_eq!(message.tokens.cache_read, 10);
assert_eq!(message.tokens.output, 20);
assert_eq!(message.tokens.reasoning, 0);
assert_eq!(message.tokens.total(), 120);
assert_eq!(message.message_count, 3);
}
#[test]
fn skips_turn_markers_malformed_and_zero_usage_records() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","turn":true}
not json
{"ts":"2026-08-04T09:01:00Z","model":"","total":10}
{"ts":"2026-08-04T09:02:00Z","model":"x","total":0,"requests":0}
{"ts":"2026-08-04T09:03:00Z","model":"x","prompt":3,"completion":2,"total":5,"requests":0}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tokens.total(), 5);
assert_eq!(messages[0].message_count, 1);
}
#[test]
fn preserves_unknown_model_provider_as_reasonix_only_when_not_inferable() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","model":"zzz-unknown-vendor-model","total":7,"requests":1}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].provider_id, CLIENT_ID);
}
#[test]
fn split_model_ref_handles_provider_slash_and_inference() {
let (provider, model) = split_model_ref("deepseek/chat");
assert_eq!(provider, "deepseek");
assert_eq!(model, "chat");
let (provider, model) = split_model_ref("openrouter/google/gemini-2.5-pro");
assert_eq!(provider, "openrouter");
assert_eq!(model, "google/gemini-2.5-pro");
let (provider, _) = split_model_ref("claude-sonnet-4");
assert_eq!(provider, "anthropic");
}
#[test]
fn preserves_explicit_cache_miss_when_it_disagrees_with_prompt_input() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","model":"x/y","prompt":100,"completion":1,"cache_hit":10,"cache_miss":50,"total":61,"requests":1}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tokens.input, 50);
assert_eq!(messages[0].tokens.cache_read, 10);
}
#[test]
fn falls_back_to_prompt_minus_cache_hit_when_cache_miss_is_absent() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","model":"x/y","prompt":40,"completion":1,"cache_hit":15,"total":56,"requests":1}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tokens.input, 25);
assert_eq!(messages[0].tokens.cache_read, 15);
}
#[test]
fn reasoning_is_clamped_into_completion_so_buckets_stay_additive() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","model":"x/y","prompt":10,"completion":8,"reasoning":20,"total":26,"requests":1}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tokens.reasoning, 8);
assert_eq!(messages[0].tokens.output, 0);
assert_eq!(messages[0].tokens.total(), 18);
}
#[test]
fn maps_authoritative_request_count_to_bounded_message_count() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","model":"x/y","total":1,"requests":9999999999}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message_count, i32::MAX);
}
#[test]
fn preserves_tokenless_request_counts_but_skips_plain_zero_rows() {
let file = write_stats(
r#"{"ts":"2026-08-04T09:00:00Z","model":"x/y","total":0,"requests":2}
{"ts":"2026-08-04T09:01:00Z","model":"x/y","total":0,"requests":0}"#,
);
let messages = parse_reasonix_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tokens.total(), 0);
assert_eq!(messages[0].message_count, 2);
}
}