use crate::prices::{self, Tokens};
use crate::store::{Filled, Unfilled};
use anyhow::Result;
mod readers;
pub const LOOKBACK: i64 = 30 * 86_400;
pub const GRACE: i64 = 15 * 60;
pub const WINDOW: i64 = 300;
#[derive(Debug, Clone, PartialEq)]
pub struct Call {
pub at: i64,
pub tool: String,
pub args: String,
pub found: Found,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Found {
pub model: Option<String>,
pub provider: Option<String>,
pub tokens: Option<Tokens>,
pub cost: Option<f64>,
pub source: String,
}
#[derive(Debug)]
pub enum Log {
Calls(Vec<Call>),
NotRecorded(String),
}
pub fn semlith_tool(name: &str) -> Option<String> {
let at = name.rfind("semlith_")?;
if at > 0 && name.as_bytes()[at - 1].is_ascii_alphanumeric() {
return None;
}
let tool = &name[at + "semlith_".len()..];
(!tool.is_empty()).then(|| tool.to_string())
}
fn asked(args: &str, query: &str) -> bool {
if query.is_empty() {
return false;
}
if args.contains(query) {
return true;
}
let escaped = serde_json::to_string(query).unwrap_or_default();
escaped.len() > 2 && args.contains(&escaped[1..escaped.len() - 1])
}
pub fn assign(
rows: &[Unfilled],
calls: &[Call],
client: &str,
now: i64,
table: &prices::Table,
taken: &mut std::collections::HashSet<String>,
) -> Vec<(Unfilled, Filled)> {
let key = |c: &Call| {
format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
c.found.source, c.tool, c.at, c.args
)
};
let mut out = Vec::new();
for row in rows {
let best = calls
.iter()
.enumerate()
.filter(|(_, c)| {
c.tool == row.tool && (c.at - row.at).abs() <= WINDOW && !taken.contains(&key(c))
})
.min_by_key(|(_, c)| (!asked(&c.args, &row.query), (c.at - row.at).abs()))
.map(|(i, _)| i);
match best {
Some(i) => {
taken.insert(key(&calls[i]));
out.push((row.clone(), priced(&calls[i].found, table)));
}
None if now - row.at > GRACE => out.push((
row.clone(),
Filled {
usage_source: if crate::clientid::generic(client) {
format!("no matching call in any client's log (the row names {client:?}, not an app)")
} else {
format!("no matching call in {client}'s log")
},
..Default::default()
},
)),
None => {}
}
}
out
}
pub fn priced(found: &Found, table: &prices::Table) -> Filled {
let (cost_usd, cost_source) = match (found.cost, &found.model, found.tokens) {
(Some(cost), _, _) => (Some(cost), Some("client".to_string())),
(None, Some(model), Some(tokens)) => {
match prices::lookup(table, found.provider.as_deref(), model) {
Some((_, price)) => (Some(prices::cost(price, &tokens)), Some(table.label())),
None => (None, None),
}
}
_ => (None, None),
};
Filled {
model: found.model.clone(),
tokens: found.tokens,
cost_usd,
cost_source,
usage_source: found.source.clone(),
}
}
pub fn log_paths() -> Vec<serde_json::Value> {
let home = crate::home::user_home()
.map(|h| crate::plain(&h.display().to_string()))
.unwrap_or_default();
let short = |p: &std::path::PathBuf| {
let full = crate::plain(&p.display().to_string());
match full.strip_prefix(&home) {
Some(rest) if !home.is_empty() => format!("~{rest}"),
_ => full,
}
};
readers::paths()
.into_iter()
.map(|(client, paths)| {
serde_json::json!({
"client": client,
"paths": paths.iter().map(short).collect::<Vec<_>>(),
})
})
.collect()
}
pub fn enabled() -> bool {
crate::home::Settings::load().ledger_usage.unwrap_or(false)
}
pub fn enrich(db: &rusqlite::Connection, now: i64) -> Result<usize> {
if !enabled() {
return Ok(0);
}
let rows = crate::store::unfilled(db, now - LOOKBACK)?;
if rows.is_empty() {
return Ok(0);
}
let table = prices::table();
let mut by_client: std::collections::BTreeMap<String, Vec<Unfilled>> = Default::default();
for row in rows {
by_client.entry(row.client.clone()).or_default().push(row);
}
let mut clients: Vec<(String, Vec<Unfilled>)> = by_client.into_iter().collect();
clients.sort_by_key(|(client, _)| crate::clientid::generic(client));
let mut taken = std::collections::HashSet::new();
let mut written = 0;
for (client, rows) in clients {
let since = rows.iter().map(|r| r.at).min().unwrap_or(now) - WINDOW;
let filled = match readers::read(&client, since) {
Ok(Log::Calls(calls)) => assign(&rows, &calls, &client, now, &table, &mut taken),
Ok(Log::NotRecorded(why)) => rows
.into_iter()
.map(|row| {
let filled = Filled {
usage_source: why.clone(),
..Default::default()
};
(row, filled)
})
.collect(),
Err(_) => continue,
};
for (row, filled) in &filled {
crate::store::fill_usage(db, row, filled)?;
}
written += filled.len();
}
Ok(written)
}
#[cfg(test)]
mod tests {
use super::*;
fn row(id: i64, at: i64, tool: &str, query: &str) -> Unfilled {
Unfilled {
id,
at,
client: "Claude Code".into(),
session: String::new(),
tool: tool.into(),
query: query.into(),
query_id: format!("q{id}"),
}
}
fn call(at: i64, tool: &str, args: &str, model: &str) -> Call {
Call {
at,
tool: tool.into(),
args: args.into(),
found: Found {
model: Some(model.into()),
tokens: Some(Tokens {
input: 10,
output: 20,
cache_read: 1_000,
cache_write: 0,
reasoning: 0,
cache_write_1h: 0,
}),
source: "log.jsonl".into(),
..Default::default()
},
}
}
#[test]
fn tool_names_are_read_under_every_prefix() {
assert_eq!(
semlith_tool("mcp__semlith__semlith_search").as_deref(),
Some("search")
);
assert_eq!(
semlith_tool("semlith.semlith_read").as_deref(),
Some("read")
);
assert_eq!(semlith_tool("semlith_brief").as_deref(), Some("brief"));
assert_eq!(semlith_tool("mysemlith_search"), None);
assert_eq!(semlith_tool("Read"), None);
}
#[test]
fn rows_match_the_call_that_asked_nearest_in_time() {
let table = prices::built_in();
let rows = [
row(1, 1_000, "search", "where is x"),
row(2, 1_001, "search", "who calls \"y\""),
];
let calls = [
call(
999,
"search",
r#"{"query":"who calls \"y\""}"#,
"claude-opus-4-5",
),
call(
998,
"search",
r#"{"query":"where is x"}"#,
"claude-sonnet-4-5",
),
call(
1_000,
"read",
r#"{"target":"where is x"}"#,
"claude-haiku-4-5",
),
];
let got = assign(
&rows,
&calls,
"Claude Code",
1_010,
&table,
&mut Default::default(),
);
assert_eq!(got.len(), 2);
assert_eq!(got[0].1.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(got[1].1.model.as_deref(), Some("claude-opus-4-5"));
assert!(
got[0]
.1
.cost_source
.as_deref()
.unwrap()
.starts_with("models.dev ")
);
assert!(got[0].1.cost_usd.unwrap() > 0.0);
}
#[test]
fn a_call_one_client_took_is_not_taken_again() {
let table = prices::built_in();
let calls = [call(1_000, "search", r#"{"query":"q"}"#, "claude-opus-4-5")];
let mut taken = Default::default();
let first = assign(
&[row(1, 1_000, "search", "q")],
&calls,
"Claude Code",
1_010,
&table,
&mut taken,
);
assert_eq!(first.len(), 1);
let second = assign(
&[row(2, 1_000, "search", "q")],
&calls,
"mcp",
1_010,
&table,
&mut taken,
);
assert!(second.is_empty(), "{second:?}");
}
#[test]
fn a_row_waits_for_its_log_then_is_marked() {
let table = prices::built_in();
let rows = [row(1, 1_000, "search", "q")];
assert!(
assign(
&rows,
&[],
"Zed",
1_000 + GRACE,
&table,
&mut Default::default()
)
.is_empty()
);
let late = assign(
&rows,
&[],
"Zed",
1_001 + GRACE,
&table,
&mut Default::default(),
);
assert_eq!(late[0].1.usage_source, "no matching call in Zed's log");
assert_eq!(late[0].1.model, None);
let far = [call(1_000 + WINDOW + 1, "search", r#"{"query":"q"}"#, "m")];
assert_eq!(
assign(&rows, &far, "Zed", 1_000, &table, &mut Default::default()).len(),
0
);
}
#[test]
fn the_clients_own_cost_wins_and_an_unknown_model_has_none() {
let table = prices::built_in();
let mut found = call(0, "search", "", "claude-opus-4-5").found;
found.cost = Some(0.0123);
let filled = priced(&found, &table);
assert_eq!(
(filled.cost_usd, filled.cost_source.as_deref()),
(Some(0.0123), Some("client"))
);
found.cost = None;
found.model = Some("a-model-nobody-prices".into());
let filled = priced(&found, &table);
assert_eq!((filled.cost_usd, filled.cost_source), (None, None));
assert!(filled.tokens.is_some());
}
}