atman-runtime 1.8.0

atman flow execution runtime: evaluator, tool dispatch, provider dispatch, executor, memory stores
Documentation
use std::collections::HashMap;

use crate::event::{Event, LlmCallStatus};
use crate::provider::TokenUsage;

#[derive(Debug, Default, Clone)]
pub struct CostSummary {
    pub calls: u64,
    pub failures: u64,
    pub usage: TokenUsage,
    pub wallclock_ms: u64,
}

impl CostSummary {
    fn accumulate(&mut self, usage: &TokenUsage, wallclock_ms: u64, failed: bool) {
        self.calls += 1;
        if failed {
            self.failures += 1;
        }
        self.usage.input = self.usage.input.saturating_add(usage.input);
        self.usage.cached_input = self.usage.cached_input.saturating_add(usage.cached_input);
        self.usage.output = self.usage.output.saturating_add(usage.output);
        self.usage.cache_write = self.usage.cache_write.saturating_add(usage.cache_write);
        self.wallclock_ms = self.wallclock_ms.saturating_add(wallclock_ms);
    }
}

pub fn summarize_by_model(events: &[Event]) -> HashMap<String, CostSummary> {
    let mut out: HashMap<String, CostSummary> = HashMap::new();
    for e in events {
        if let Event::LlmCall {
            model,
            usage,
            wallclock_ms,
            status,
            ..
        } = e
        {
            let entry = out.entry(model.clone()).or_default();
            entry.accumulate(
                usage,
                *wallclock_ms,
                matches!(status, LlmCallStatus::Errored { .. }),
            );
        }
    }
    out
}

pub fn summarize_by_provider(events: &[Event]) -> HashMap<String, CostSummary> {
    let mut out: HashMap<String, CostSummary> = HashMap::new();
    for e in events {
        if let Event::LlmCall {
            provider,
            usage,
            wallclock_ms,
            status,
            ..
        } = e
        {
            let entry = out.entry(provider.clone()).or_default();
            entry.accumulate(
                usage,
                *wallclock_ms,
                matches!(status, LlmCallStatus::Errored { .. }),
            );
        }
    }
    out
}

pub fn total(events: &[Event]) -> CostSummary {
    let mut acc = CostSummary::default();
    for e in events {
        if let Event::LlmCall {
            usage,
            wallclock_ms,
            status,
            ..
        } = e
        {
            acc.accumulate(
                usage,
                *wallclock_ms,
                matches!(status, LlmCallStatus::Errored { .. }),
            );
        }
    }
    acc
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ok_call(model: &str, provider: &str, in_tok: u64, out_tok: u64) -> Event {
        Event::LlmCall {
            model: model.into(),
            provider: provider.into(),
            usage: TokenUsage {
                input: in_tok,
                output: out_tok,
                ..Default::default()
            },
            wallclock_ms: 100,
            status: LlmCallStatus::Ok,
            ttft_ms: None,
            tokens_per_second: None,
            run_id: None,
            node_id: None,
        }
    }

    fn err_call(model: &str) -> Event {
        Event::LlmCall {
            model: model.into(),
            provider: "p".into(),
            usage: TokenUsage {
                input: 5,
                ..Default::default()
            },
            wallclock_ms: 50,
            status: LlmCallStatus::Errored {
                message: "boom".into(),
            },
            ttft_ms: None,
            tokens_per_second: None,
            run_id: None,
            node_id: None,
        }
    }

    #[test]
    fn summarize_by_model_groups_multiple_calls() {
        let events = vec![
            ok_call("opus", "anthropic", 10, 20),
            ok_call("opus", "anthropic", 5, 8),
            ok_call("mini", "openai", 3, 4),
        ];
        let s = summarize_by_model(&events);
        assert_eq!(s.get("opus").unwrap().calls, 2);
        assert_eq!(s.get("opus").unwrap().usage.input, 15);
        assert_eq!(s.get("opus").unwrap().usage.output, 28);
        assert_eq!(s.get("mini").unwrap().calls, 1);
    }

    #[test]
    fn failures_counted_separately() {
        let events = vec![ok_call("m", "p", 1, 2), err_call("m"), err_call("m")];
        let s = summarize_by_model(&events);
        assert_eq!(s.get("m").unwrap().calls, 3);
        assert_eq!(s.get("m").unwrap().failures, 2);
    }

    #[test]
    fn total_sums_wallclock() {
        let events = vec![ok_call("a", "p", 1, 1), ok_call("b", "p", 2, 2)];
        let t = total(&events);
        assert_eq!(t.calls, 2);
        assert_eq!(t.wallclock_ms, 200);
    }

    #[test]
    fn non_llm_events_ignored() {
        use crate::event::{FlowRunId, FlowStatus};
        let run_id = FlowRunId::now();
        let events = vec![
            Event::FlowStart {
                run_id: run_id.clone(),
                flow_name: "t".into(),
                parent_run_id: None,
                parent_node_id: None,
                spawned: false,
            },
            ok_call("m", "p", 5, 5),
            Event::FlowEnd {
                run_id,
                flow_name: "t".into(),
                status: FlowStatus::Ok,
            },
        ];
        let t = total(&events);
        assert_eq!(t.calls, 1);
        assert_eq!(t.usage.input, 5);
    }
}