Skip to main content

ninox_core/lifecycle/
usage.rs

1//! Cost/token usage ingestion for interactive `claude-code` sessions.
2//!
3//! Ninox launches the real `claude` CLI interactively inside a tmux pane
4//! (see `harness::HarnessRegistry::interactive_cmd`/`worker_cmd`) rather than driving it
5//! through a scripted API, so there is no request/response boundary Ninox
6//! controls where a cost or token count could be captured directly. What
7//! *does* exist is the CLI's own on-disk transcript: every turn is appended
8//! as a JSON line to
9//!
10//! ```text
11//! ~/.claude/projects/<escaped-workspace-path>/<claude-session-uuid>.jsonl
12//! ```
13//!
14//! where the directory name is the session's working directory with every
15//! non-alphanumeric character replaced by `-`, and each `assistant`-type
16//! line carries a `message.usage` object (`input_tokens`, `output_tokens`,
17//! `cache_creation_input_tokens`, `cache_read_input_tokens`) plus
18//! `message.model`. Since each Ninox session (orchestrator subdirectory,
19//! standalone worktree, or CLI worker worktree) runs in its own dedicated
20//! workspace directory, that directory is a reliable 1:1 key back to the
21//! session — no `ATHENE_SESSION`-style attribution needed for this part.
22//!
23//! There is no on-disk USD figure to read, so cost is estimated from token
24//! counts against a small built-in pricing table. These are **rough
25//! priors, not live pricing** — good enough to answer "is this session
26//! burning money" and to seed the spawn-modal's data-driven estimate, not
27//! to reconcile an invoice.
28
29use std::{
30    io::{BufRead, BufReader},
31    path::{Path, PathBuf},
32};
33
34/// A snapshot of a workspace's accumulated `claude` usage, computed by
35/// summing every turn recorded in its transcript(s).
36#[derive(Debug, Clone, PartialEq)]
37pub struct UsageSnapshot {
38    /// Estimated total spend across every turn in every transcript found
39    /// for the workspace (a workspace may accumulate multiple transcripts
40    /// across resumed/restarted `claude` invocations).
41    pub cost_usd:       f64,
42    /// Context-window occupancy (input + cache-read + cache-creation
43    /// tokens) as of the most recent turn — an approximation of "how full
44    /// is the context window right now", not a cumulative token count.
45    pub context_tokens: u64,
46    /// Model of the most recent turn, if any usage was found.
47    pub model:          Option<String>,
48}
49
50/// Directory Ninox expects `claude`'s own per-project transcripts to live
51/// under. Honors `NINOX_CLAUDE_PROJECTS_DIR` as a test/override seam
52/// (mirrors `AppConfig::resolved_brain_path`'s `NINOX_BRAIN` pattern);
53/// otherwise `~/.claude/projects`.
54pub fn claude_projects_dir() -> PathBuf {
55    if let Ok(p) = std::env::var("NINOX_CLAUDE_PROJECTS_DIR") {
56        if !p.is_empty() {
57            return PathBuf::from(p);
58        }
59    }
60    dirs::home_dir()
61        .unwrap_or_else(|| PathBuf::from("."))
62        .join(".claude")
63        .join("projects")
64}
65
66/// The directory-name `claude` derives from a working-directory path: every
67/// byte that isn't an ASCII letter or digit becomes `-`.
68///
69/// Verified against real `~/.claude/projects/*` directory names, e.g.
70/// `/Users/x/proj/.claude/worktrees/y` → `-Users-x-proj--claude-worktrees-y`
71/// (the doubled dash comes from the `/` before `.claude` *and* the `.`
72/// itself each becoming their own `-`).
73pub fn claude_project_slug(workspace: &str) -> String {
74    workspace
75        .chars()
76        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
77        .collect()
78}
79
80/// Rough (input $/1M tokens, output $/1M tokens) pricing priors per model
81/// family. Not live pricing — see module docs.
82fn price_per_million(model: &str) -> (f64, f64) {
83    if model.contains("fable") {
84        (20.0, 100.0)
85    } else if model.contains("opus") {
86        (15.0, 75.0)
87    } else if model.contains("sonnet") {
88        (3.0, 15.0)
89    } else if model.contains("haiku") {
90        (0.8, 4.0)
91    } else {
92        // Unknown/unrecognized model — assume sonnet-tier as a middle-of-
93        // the-road default rather than under- or over-counting wildly.
94        (3.0, 15.0)
95    }
96}
97
98/// Cost of a single turn given its raw usage counters. Cache reads are
99/// billed at a fraction of the base input rate and cache writes at a
100/// premium over it, mirroring the ratios real prompt-caching pricing uses.
101fn turn_cost_usd(model: &str, input: u64, output: u64, cache_creation: u64, cache_read: u64) -> f64 {
102    let (in_rate, out_rate) = price_per_million(model);
103    let cache_read_rate  = in_rate * 0.1;
104    let cache_write_rate = in_rate * 1.25;
105    (input as f64          * in_rate
106        + output as f64        * out_rate
107        + cache_read as f64    * cache_read_rate
108        + cache_creation as f64 * cache_write_rate)
109        / 1_000_000.0
110}
111
112/// One turn's usage fields, extracted from a transcript line.
113struct TurnUsage {
114    model:          String,
115    input:          u64,
116    output:         u64,
117    cache_creation: u64,
118    cache_read:     u64,
119    timestamp:      String,
120}
121
122fn parse_turn(line: &str) -> Option<TurnUsage> {
123    let v: serde_json::Value = serde_json::from_str(line).ok()?;
124    if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
125        return None;
126    }
127    let usage = v.pointer("/message/usage")?;
128    let get_u64 = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
129    Some(TurnUsage {
130        model:          v.pointer("/message/model").and_then(|m| m.as_str()).unwrap_or("").to_string(),
131        input:          get_u64("input_tokens"),
132        output:         get_u64("output_tokens"),
133        cache_creation: get_u64("cache_creation_input_tokens"),
134        cache_read:     get_u64("cache_read_input_tokens"),
135        timestamp:      v.get("timestamp").and_then(|t| t.as_str()).unwrap_or("").to_string(),
136    })
137}
138
139/// Sum usage across every `*.jsonl` transcript in `dir` (non-recursive).
140/// Returns `None` if the directory doesn't exist or no assistant-usage
141/// lines were found in it.
142fn ingest_dir(dir: &Path) -> Option<UsageSnapshot> {
143    let entries = std::fs::read_dir(dir).ok()?;
144
145    let mut total_cost = 0.0f64;
146    let mut latest_ts   = String::new();
147    let mut latest_context: u64 = 0;
148    let mut latest_model: Option<String> = None;
149    let mut found = false;
150
151    for entry in entries.flatten() {
152        let path = entry.path();
153        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
154            continue;
155        }
156        let Ok(file) = std::fs::File::open(&path) else { continue };
157        for line in BufReader::new(file).lines().map_while(Result::ok) {
158            let Some(turn) = parse_turn(&line) else { continue };
159            found = true;
160            total_cost += turn_cost_usd(&turn.model, turn.input, turn.output, turn.cache_creation, turn.cache_read);
161            if turn.timestamp >= latest_ts {
162                latest_ts      = turn.timestamp;
163                latest_context = turn.input + turn.cache_creation + turn.cache_read;
164                latest_model   = Some(turn.model);
165            }
166        }
167    }
168
169    found.then_some(UsageSnapshot {
170        cost_usd:       total_cost,
171        context_tokens: latest_context,
172        model:          latest_model,
173    })
174}
175
176/// Compute the current usage snapshot for a session's workspace directory,
177/// by locating and summing `claude`'s own transcript(s) for that directory.
178/// Returns `None` when no transcripts exist yet (e.g. the agent hasn't sent
179/// its first turn) or the workspace can't be resolved.
180pub fn ingest_usage_for_workspace(workspace: &str) -> Option<UsageSnapshot> {
181    let dir = claude_projects_dir().join(claude_project_slug(workspace));
182    ingest_dir(&dir)
183}
184
185/// Serializes tests that mutate `NINOX_CLAUDE_PROJECTS_DIR` (process-global
186/// env state) against each other — shared across this module's tests *and*
187/// `lifecycle::poller`'s, since both mutate the same env var and Rust runs
188/// test fns on parallel threads by default.
189#[cfg(test)]
190pub(crate) static ENV_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::io::Write;
196    use tempfile::tempdir;
197
198    #[test]
199    fn slug_replaces_non_alphanumeric_with_dash() {
200        assert_eq!(
201            claude_project_slug("/Users/ethan.brodie/Library/Application Support/ninox/orchestrator/2"),
202            "-Users-ethan-brodie-Library-Application-Support-ninox-orchestrator-2",
203        );
204    }
205
206    #[test]
207    fn slug_handles_nested_dot_worktree_dirs() {
208        assert_eq!(
209            claude_project_slug("/Users/x/proj/iac/.claude/worktrees/y"),
210            "-Users-x-proj-iac--claude-worktrees-y",
211        );
212    }
213
214    #[test]
215    fn price_ordering_matches_fleet_ranking() {
216        // fable-5 highest, opus-4-8 middle, haiku-4-5 lowest — matches the
217        // spawn-modal preset ordering (AGENT_PRESETS in ninox-app).
218        let (fable_in, fable_out)   = price_per_million("claude-fable-5");
219        let (opus_in, opus_out)     = price_per_million("claude-opus-4-8");
220        let (haiku_in, haiku_out)   = price_per_million("claude-haiku-4-5");
221        assert!(fable_in > opus_in && opus_in > haiku_in);
222        assert!(fable_out > opus_out && opus_out > haiku_out);
223    }
224
225    #[test]
226    fn turn_cost_is_positive_and_scales_with_tokens() {
227        let small = turn_cost_usd("claude-opus-4-8", 100, 100, 0, 0);
228        let large = turn_cost_usd("claude-opus-4-8", 10_000, 10_000, 0, 0);
229        assert!(small > 0.0);
230        assert!(large > small);
231    }
232
233    #[test]
234    fn cache_read_is_cheaper_than_fresh_input() {
235        let fresh = turn_cost_usd("claude-sonnet-4-5", 1000, 0, 0, 0);
236        let cached = turn_cost_usd("claude-sonnet-4-5", 0, 0, 0, 1000);
237        assert!(cached < fresh);
238    }
239
240    fn write_jsonl(dir: &Path, name: &str, lines: &[&str]) {
241        let mut f = std::fs::File::create(dir.join(name)).unwrap();
242        for l in lines {
243            writeln!(f, "{l}").unwrap();
244        }
245    }
246
247    #[test]
248    fn ingest_dir_sums_cost_and_uses_latest_turn_for_context() {
249        let dir = tempdir().unwrap();
250        write_jsonl(dir.path(), "s1.jsonl", &[
251            r#"{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{"model":"claude-fable-5","usage":{"input_tokens":2,"output_tokens":100,"cache_creation_input_tokens":0,"cache_read_input_tokens":1000}}}"#,
252            r#"{"type":"assistant","timestamp":"2026-07-05T13:01:00.000Z","message":{"model":"claude-fable-5","usage":{"input_tokens":2,"output_tokens":200,"cache_creation_input_tokens":500,"cache_read_input_tokens":45000}}}"#,
253            // Non-assistant / no-usage lines must be ignored, not error out.
254            r#"{"type":"system","subtype":"turn_duration"}"#,
255            "not even json",
256        ]);
257
258        let snap = ingest_dir(dir.path()).expect("usage found");
259        assert!(snap.cost_usd > 0.0);
260        // Latest turn's context = input + cache_creation + cache_read.
261        assert_eq!(snap.context_tokens, 2 + 500 + 45000);
262        assert_eq!(snap.model.as_deref(), Some("claude-fable-5"));
263    }
264
265    #[test]
266    fn ingest_dir_sums_across_multiple_transcript_files() {
267        let dir = tempdir().unwrap();
268        write_jsonl(dir.path(), "a.jsonl", &[
269            r#"{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":10,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
270        ]);
271        write_jsonl(dir.path(), "b.jsonl", &[
272            r#"{"type":"assistant","timestamp":"2026-07-05T14:00:00.000Z","message":{"model":"claude-haiku-4-5","usage":{"input_tokens":20,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
273        ]);
274        let one_file = ingest_dir(dir.path()).unwrap();
275        // Cost is the sum of both files' turns, not just one.
276        let single = turn_cost_usd("claude-haiku-4-5", 10, 10, 0, 0)
277            + turn_cost_usd("claude-haiku-4-5", 20, 20, 0, 0);
278        assert!((one_file.cost_usd - single).abs() < 1e-12);
279        // Latest turn (by timestamp) is in b.jsonl.
280        assert_eq!(one_file.context_tokens, 20);
281    }
282
283    #[test]
284    fn ingest_dir_returns_none_for_missing_directory() {
285        let dir = tempdir().unwrap();
286        assert!(ingest_dir(&dir.path().join("does-not-exist")).is_none());
287    }
288
289    #[test]
290    fn ingest_dir_returns_none_when_no_assistant_usage_lines() {
291        let dir = tempdir().unwrap();
292        write_jsonl(dir.path(), "empty.jsonl", &[r#"{"type":"system"}"#]);
293        assert!(ingest_dir(dir.path()).is_none());
294    }
295
296    #[test]
297    fn ingest_usage_for_workspace_resolves_via_slug_and_projects_dir_override() {
298        let dir = tempdir().unwrap();
299        let workspace = "/tmp/my-workspace";
300        let project_dir = dir.path().join(claude_project_slug(workspace));
301        std::fs::create_dir_all(&project_dir).unwrap();
302        write_jsonl(&project_dir, "s.jsonl", &[
303            r#"{"type":"assistant","timestamp":"2026-07-05T13:00:00.000Z","message":{"model":"claude-opus-4-8","usage":{"input_tokens":5,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#,
304        ]);
305
306        let _guard = ENV_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
307        let prior = std::env::var("NINOX_CLAUDE_PROJECTS_DIR").ok();
308        std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", dir.path());
309
310        let snap = ingest_usage_for_workspace(workspace);
311
312        match prior {
313            Some(v) => std::env::set_var("NINOX_CLAUDE_PROJECTS_DIR", v),
314            None    => std::env::remove_var("NINOX_CLAUDE_PROJECTS_DIR"),
315        }
316
317        let snap = snap.expect("usage found via projects-dir override");
318        assert!(snap.cost_usd > 0.0);
319        assert_eq!(snap.model.as_deref(), Some("claude-opus-4-8"));
320    }
321}