1use leviath_core::run_archive::{InferenceKind, RunRecord};
18
19use crate::persistence::{RunMetadata, TokenTotals};
20use crate::persistence_bridge::PersistMsg;
21use crate::pipeline::PersistenceStage;
22
23pub struct CallUsage<'a> {
29 pub kind: InferenceKind,
31 pub stage: &'a str,
33 pub iteration: usize,
35 pub provider: &'a str,
37 pub model: &'a str,
39 pub usage: &'a leviath_providers::TokenUsage,
41}
42
43pub fn record_call(
50 totals: Option<&mut TokenTotals>,
51 persist: Option<&PersistenceStage>,
52 metadata: Option<&RunMetadata>,
53 call: &CallUsage<'_>,
54) {
55 if let Some(totals) = totals {
56 totals.add_usage(call.usage);
57 }
58 let (Some(persist), Some(md)) = (persist, metadata) else {
59 return;
60 };
61 let record = RunRecord::InferenceUsage {
62 kind: call.kind,
63 stage: call.stage.to_string(),
64 iteration: call.iteration,
65 provider: call.provider.to_string(),
66 model: call.model.to_string(),
67 prompt_tokens: call.usage.prompt_tokens,
68 completion_tokens: call.usage.completion_tokens,
69 cached_tokens: call.usage.cached_tokens,
70 cache_write_tokens: call.usage.cache_write_tokens,
71 at: chrono::Utc::now().timestamp(),
72 };
73 let _ = persist.0.send(PersistMsg::Append {
77 run_id: md.run_id.clone(),
78 record: Box::new(record),
79 ack: None,
80 });
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 fn usage() -> leviath_providers::TokenUsage {
88 leviath_providers::TokenUsage {
89 prompt_tokens: 100,
90 completion_tokens: 20,
91 cached_tokens: 3,
92 cache_write_tokens: 4,
93 total_tokens: 120,
94 }
95 }
96
97 fn metadata() -> RunMetadata {
98 RunMetadata {
99 run_id: "run-u".to_string(),
100 agent_name: "a".to_string(),
101 agent_path: "/a".to_string(),
102 task: "t".to_string(),
103 model: None,
104 workdir: "/w".to_string(),
105 num_stages: 1,
106 started_at: 0,
107 parent_run_id: None,
108 metadata: Default::default(),
109 callback_url: None,
110 callback_secret: None,
111 title: None,
112 unattended: false,
113 read_paths: None,
114 output_request: None,
115 }
116 }
117
118 fn call(kind: InferenceKind, u: &leviath_providers::TokenUsage) -> CallUsage<'_> {
119 CallUsage {
120 kind,
121 stage: "plan",
122 iteration: 2,
123 provider: "anthropic",
124 model: "claude-sonnet-5",
125 usage: u,
126 }
127 }
128
129 #[test]
134 fn counting_and_journaling_are_independent() {
135 let u = usage();
136
137 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
139 let tx_for_noise = tx.clone();
140 let mut totals = TokenTotals::default();
141 record_call(
142 Some(&mut totals),
143 Some(&PersistenceStage(tx)),
144 Some(&metadata()),
145 &call(InferenceKind::Compaction, &u),
146 );
147 assert_eq!(totals.prompt_tokens, 100);
148 let _ = tx_for_noise.send(crate::persistence_bridge::PersistMsg::StageLines {
152 run_id: "run-u".to_string(),
153 output_appends: vec![],
154 log_appends: vec![],
155 });
156 let mut appended: Vec<(String, RunRecord)> = Vec::new();
157 while let Ok(msg) = rx.try_recv() {
158 if let crate::persistence_bridge::PersistMsg::Append { run_id, record, .. } = msg {
159 appended.push((run_id, *record));
160 }
161 }
162 assert_eq!(appended.len(), 1, "one call, one record");
163 let (run_id, record) = appended.remove(0);
164 assert_eq!(run_id, "run-u");
165 let mut value = serde_json::to_value(&record).unwrap();
169 let fields = value["InferenceUsage"].as_object_mut().unwrap();
170 assert!(fields.remove("at").is_some(), "a call is stamped");
171 assert_eq!(
172 value,
173 serde_json::json!({
174 "InferenceUsage": {
175 "kind": "compaction",
176 "stage": "plan",
177 "iteration": 2,
178 "provider": "anthropic",
179 "model": "claude-sonnet-5",
180 "prompt_tokens": 100,
181 "completion_tokens": 20,
182 "cached_tokens": 3,
183 "cache_write_tokens": 4,
184 }
185 })
186 );
187
188 let mut totals = TokenTotals::default();
190 record_call(
191 Some(&mut totals),
192 None,
193 Some(&metadata()),
194 &call(InferenceKind::Stage, &u),
195 );
196 assert_eq!(totals.prompt_tokens, 100);
197
198 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
201 let mut totals = TokenTotals::default();
202 record_call(
203 Some(&mut totals),
204 Some(&PersistenceStage(tx)),
205 None,
206 &call(InferenceKind::Title, &u),
207 );
208 assert_eq!(totals.prompt_tokens, 100);
209 assert!(rx.try_recv().is_err());
210
211 record_call(None, None, None, &call(InferenceKind::Routing, &u));
213 }
214
215 #[test]
219 fn repeated_calls_accumulate() {
220 let u = usage();
221 let mut totals = TokenTotals::default();
222 for _ in 0..3 {
223 record_call(
224 Some(&mut totals),
225 None,
226 None,
227 &call(InferenceKind::Stage, &u),
228 );
229 }
230 assert_eq!(totals.prompt_tokens, 300);
231 assert_eq!(totals.completion_tokens, 60);
232 assert_eq!(totals.cached_tokens, 9);
233 assert_eq!(totals.cache_write_tokens, 12);
234 }
235}