ragit_api/
audit.rs

1use chrono::{Datelike, DateTime, Local, Utc};
2use crate::Error;
3use ragit_fs::{
4    WriteMode,
5    create_dir_all,
6    exists,
7    parent,
8    read_string,
9    write_string,
10};
11use ragit_pdl::{Message, JsonType};
12use serde_json::{Map, Value};
13use std::collections::HashMap;
14use std::ops::AddAssign;
15
16#[derive(Clone, Debug)]
17pub struct AuditRecordAt {
18    pub path: String,
19    pub id: String,
20}
21
22#[derive(Clone, Copy, Debug)]
23pub struct AuditRecord {
24    pub input_tokens: u64,
25    pub output_tokens: u64,
26
27    // Divide this by 1 million to get dollars
28    pub input_cost: u64,
29    pub output_cost: u64,
30}
31
32impl AddAssign<AuditRecord> for AuditRecord {
33    fn add_assign(&mut self, rhs: AuditRecord) {
34        self.input_tokens += rhs.input_tokens;
35        self.output_tokens += rhs.output_tokens;
36        self.input_cost += rhs.input_cost;
37        self.output_cost += rhs.output_cost;
38    }
39}
40
41impl From<&AuditRecord> for Value {
42    fn from(r: &AuditRecord) -> Value {
43        Value::Array(vec![
44            Value::from(r.input_tokens),
45            Value::from(r.output_tokens),
46            Value::from(r.input_cost),
47            Value::from(r.output_cost),
48        ])
49    }
50}
51
52impl TryFrom<&Value> for AuditRecord {
53    type Error = Error;
54
55    fn try_from(j: &Value) -> Result<AuditRecord, Error> {
56        let mut result = vec![];
57
58        match &j {
59            Value::Array(arr) => {
60                if arr.len() != 4 {
61                    return Err(Error::WrongSchema(format!("expected an array of length 4, but got length {}", arr.len())));
62                }
63
64                for r in arr.iter() {
65                    match r.as_u64() {
66                        Some(n) => {
67                            result.push(n);
68                        },
69                        None => {
70                            return Err(Error::JsonTypeError {
71                                expected: JsonType::U64,
72                                got: r.into(),
73                            });
74                        },
75                    }
76                }
77
78                Ok(AuditRecord {
79                    input_tokens: result[0],
80                    output_tokens: result[1],
81                    input_cost: result[2],
82                    output_cost: result[3],
83                })
84            },
85            _ => Err(Error::JsonTypeError {
86                expected: JsonType::Array,
87                got: j.into(),
88            }),
89        }
90    }
91}
92
93fn records_from_json(j: &Value) -> Result<HashMap<String, AuditRecord>, Error> {
94    match j {
95        Value::Object(obj) => {
96            let mut result = HashMap::with_capacity(obj.len());
97
98            for (key, value) in obj.iter() {
99                result.insert(key.to_string(), AuditRecord::try_from(value)?);
100            }
101
102            Ok(result)
103        },
104        Value::Array(arr) => {
105            let mut result: HashMap<String, AuditRecord> = HashMap::new();
106
107            for r in arr.iter() {
108                let AuditRecordLegacy {
109                    time,
110                    input,
111                    output,
112                    input_weight,
113                    output_weight,
114                } = AuditRecordLegacy::try_from(r)?;
115                // NOTE: RecordLegacy -> Record conversion might introduce a few hours of errors.
116                let date = match DateTime::<Utc>::from_timestamp(time as i64, 0) {
117                    Some(date) => format!("{:04}{:02}{:02}", date.year(), date.month(), date.day()),
118                    None => format!("19700101"),
119                };
120                let new_record = AuditRecord {
121                    input_tokens: input,
122                    output_tokens: output,
123                    input_cost: input * input_weight / 1000,
124                    output_cost: output * output_weight / 1000,
125                };
126
127                match result.get_mut(&date) {
128                    Some(record) => { *record += new_record; },
129                    None => { result.insert(date, new_record); },
130                }
131            }
132
133            Ok(result)
134        },
135        _ => Err(Error::JsonTypeError {
136            expected: JsonType::Object,
137            got: j.into(),
138        }),
139    }
140}
141
142#[derive(Clone)]
143pub struct Tracker(pub HashMap<String, HashMap<String, AuditRecord>>);  // user_name -> usage
144
145impl Tracker {
146    pub fn new() -> Self {
147        Tracker(HashMap::new())
148    }
149
150    pub fn load_from_file(path: &str) -> Result<Self, Error> {
151        let content = read_string(path)?;
152        let j: Value = serde_json::from_str(&content)?;
153        Tracker::try_from(&j)
154    }
155
156    pub fn save_to_file(&self, path: &str) -> Result<(), Error> {
157        Ok(write_string(
158            path,
159            &serde_json::to_string_pretty(&Value::from(self))?,
160            WriteMode::Atomic,
161        )?)
162    }
163}
164
165impl TryFrom<&Value> for Tracker {
166    type Error = Error;
167
168    fn try_from(v: &Value) -> Result<Tracker, Error> {
169        match v {
170            Value::Object(obj) => {
171                let mut result = HashMap::new();
172
173                for (k, v) in obj.iter() {
174                    result.insert(k.to_string(), records_from_json(v)?);
175                }
176
177                Ok(Tracker(result))
178            },
179            _ => Err(Error::JsonTypeError {
180                expected: JsonType::Object,
181                got: v.into(),
182            }),
183        }
184    }
185}
186
187impl From<&Tracker> for Value {
188    fn from(t: &Tracker) -> Value {
189        Value::Object(t.0.iter().map(
190            |(id, records)| (
191                id.to_string(),
192                Value::Object(
193                    records.iter().map(
194                        |(date, record)| (
195                            date.to_string(),
196                            Value::from(record),
197                        )
198                    ).collect::<Map<_, _>>()
199                ),
200            )
201        ).collect())
202    }
203}
204
205pub fn dump_api_usage(
206    at: &AuditRecordAt,
207    input_tokens: u64,
208    output_tokens: u64,
209
210    // dollars per 1 billion tokens
211    input_weight: u64,
212    output_weight: u64,
213
214    // legacy option
215    _clean_up_records: bool,
216) -> Result<(), Error> {
217    let mut tracker = Tracker::load_from_file(&at.path)?;
218    let today = Local::now();
219    let today = format!("{:04}{:02}{:02}", today.year(), today.month(), today.day());
220    let new_record = AuditRecord {
221        input_tokens,
222        output_tokens,
223        input_cost: input_tokens * input_weight / 1000,
224        output_cost: output_tokens * output_weight / 1000,
225    };
226
227    match tracker.0.get_mut(&at.id) {
228        Some(records) => match records.get_mut(&today) {
229            Some(record) => {
230                *record += new_record;
231            },
232            None => {
233                records.insert(today, new_record);
234            },
235        },
236        None => {
237            tracker.0.insert(at.id.clone(), [(today, new_record)].into_iter().collect());
238        },
239    }
240
241    tracker.save_to_file(&at.path)?;
242    Ok(())
243}
244
245pub fn get_user_usage_data_since(at: AuditRecordAt, since: DateTime<Local>) -> Option<HashMap<String, AuditRecord>> {
246    let since = format!("{:04}{:02}{:02}", since.year(), since.month(), since.day());
247
248    match Tracker::load_from_file(&at.path) {
249        Ok(tracker) => match tracker.0.get(&at.id) {
250            Some(records) => Some(records.iter().filter(
251                |(date, _)| date >= &&since
252            ).map(
253                |(date, record)| (date.to_string(), record.clone())
254            ).collect()),
255            None => None,
256        },
257        _ => None,
258    }
259}
260
261pub fn get_usage_data_since(path: &str, since: DateTime<Local>) -> Option<HashMap<String, AuditRecord>> {
262    let since = format!("{:04}{:02}{:02}", since.year(), since.month(), since.day());
263
264    match Tracker::load_from_file(path) {
265        Ok(tracker) => {
266            let mut result = HashMap::new();
267
268            for records in tracker.0.values() {
269                for (date, record) in records.iter() {
270                    if date >= &since {
271                        result.insert(date.to_string(), record.clone());
272                    }
273                }
274            }
275
276            Some(result)
277        },
278        _ => None,
279    }
280}
281
282/// It returns the cost in dollars (in a formatted string), without any currency unit.
283pub fn calc_usage(records: &HashMap<String, AuditRecord>) -> String {
284    // cost * 1M
285    let mut total: u64 = records.values().map(
286        |AuditRecord { input_cost, output_cost, .. }| *input_cost + *output_cost
287    ).sum();
288
289    // cost * 1K
290    total /= 1000;
291
292    format!("{:.3}", total as f64 / 1_000.0)
293}
294
295pub fn dump_pdl(
296    messages: &[Message],
297    response: &str,
298    reasoning: &Option<String>,
299    path: &str,
300    metadata: String,
301) -> Result<(), Error> {
302    let mut markdown = vec![];
303
304    for message in messages.iter() {
305        markdown.push(format!(
306            "\n\n<|{:?}|>\n\n{}",
307            message.role,
308            message.content.iter().map(|c| c.to_string()).collect::<Vec<String>>().join(""),
309        ));
310    }
311
312    markdown.push(format!(
313        "\n\n<|Assistant|>{}\n\n{response}",
314        if let Some(reasoning) = reasoning {
315            format!("\n\n<|Reasoning|>\n\n{reasoning}\n\n")
316        } else {
317            String::new()
318        },
319    ));
320    markdown.push(format!("{}# {metadata} #{}", '{', '}'));  // tera format
321
322    if let Ok(parent) = parent(path) {
323        if !exists(&parent) {
324            create_dir_all(&parent)?;
325        }
326    }
327
328    write_string(
329        path,
330        &markdown.join("\n"),
331        WriteMode::CreateOrTruncate,
332    )?;
333
334    Ok(())
335}
336
337/*
338 * Below is a previous implementation of `AuditRecord`.
339 * I found it painfully slowing, so I rewrite it from scratch (above).
340 */
341
342impl From<AuditRecordLegacy> for Value {
343    fn from(r: AuditRecordLegacy) -> Value {
344        Value::Array(vec![
345            Value::from(r.time),
346            Value::from(r.input),
347            Value::from(r.output),
348            Value::from(r.input_weight),
349            Value::from(r.output_weight),
350        ])
351    }
352}
353
354#[derive(Clone, Copy, Debug)]
355pub struct AuditRecordLegacy {
356    pub time: u64,
357    pub input: u64,
358    pub output: u64,
359
360    // dollars per 1 billion tokens
361    pub input_weight: u64,
362    pub output_weight: u64,
363}
364
365impl TryFrom<&Value> for AuditRecordLegacy {
366    type Error = Error;
367
368    fn try_from(j: &Value) -> Result<AuditRecordLegacy, Error> {
369        let mut result = vec![];
370
371        match &j {
372            Value::Array(arr) => {
373                if arr.len() != 5 {
374                    return Err(Error::WrongSchema(format!("expected an array of length 5, but got length {}", arr.len())));
375                }
376
377                for r in arr.iter() {
378                    match r.as_u64() {
379                        Some(n) => {
380                            result.push(n);
381                        },
382                        None => {
383                            return Err(Error::JsonTypeError {
384                                expected: JsonType::U64,
385                                got: r.into(),
386                            });
387                        },
388                    }
389                }
390
391                Ok(AuditRecordLegacy {
392                    time: result[0],
393                    input: result[1],
394                    output: result[2],
395                    input_weight: result[3],
396                    output_weight: result[4],
397                })
398            },
399            _ => Err(Error::JsonTypeError {
400                expected: JsonType::Array,
401                got: j.into(),
402            }),
403        }
404    }
405}