use crate::pricing::PriceTable;
use crate::types::{Cost, LlmIo, RunLog, RunStatus, Step, StepKind, Usage};
use chrono::Utc;
use klieo_core::ids::RunId;
use klieo_core::memory::{Episode, ToolResult};
use std::time::Duration;
pub fn project(run_id: RunId, agent: impl Into<String>, episodes: &[Episode]) -> RunLog {
project_with_llm_io(run_id, agent, episodes, &[])
}
pub fn project_with_llm_io(
run_id: RunId,
agent: impl Into<String>,
episodes: &[Episode],
llm_io: &[LlmIo],
) -> RunLog {
project_with_price_table(run_id, agent, episodes, llm_io, &PriceTable::new())
}
pub fn project_with_price_table(
run_id: RunId,
agent: impl Into<String>,
episodes: &[Episode],
llm_io: &[LlmIo],
prices: &PriceTable,
) -> RunLog {
let now = Utc::now();
let mut log = RunLog {
run_id,
agent: agent.into(),
started_at: now,
finished_at: None,
status: RunStatus::Running,
steps: Vec::new(),
tokens: Usage::default(),
cost_estimate: None,
};
let mut idx: u32 = 0;
let mut llm_idx: usize = 0;
let mut prompt_usd_acc: f64 = 0.0;
let mut completion_usd_acc: f64 = 0.0;
let mut cache_usd_acc: f64 = 0.0;
let mut any_priced_step: bool = false;
for ep in episodes {
match ep {
Episode::Started { .. } => {
log.started_at = now;
}
Episode::LlmCall {
tokens,
latency_ms,
provider,
model,
prompt_tokens,
completion_tokens,
} => {
let io = llm_io.get(llm_idx);
let cost = io.and_then(|io| step_cost(io, prices)).or_else(|| {
episode_cost(
provider.as_deref(),
model.as_deref(),
*prompt_tokens,
*completion_tokens,
prices,
)
});
let (input, output, name) = match io {
Some(io) => (
serde_json::json!({ "prompt": io.prompt }),
serde_json::Value::String(io.completion.clone()),
io.model.clone().or_else(|| model.clone()),
),
None => (
serde_json::Value::Null,
serde_json::Value::Null,
model.clone(),
),
};
log.steps.push(Step {
idx,
kind: StepKind::LlmCall,
name,
prompt_tokens: *prompt_tokens,
completion_tokens: *completion_tokens,
cost_usd: cost.as_ref().map(|c| c.total_usd),
input,
output,
error: None,
latency: Duration::from_millis(u64::from(*latency_ms)),
span_id: None,
});
let split = match (*prompt_tokens, *completion_tokens) {
(Some(pt), Some(ct)) => Some((pt, ct)),
_ => io.and_then(|io| io.prompt_tokens.zip(io.completion_tokens)),
};
match split {
Some((pt, ct)) => {
log.tokens.prompt_tokens = log.tokens.prompt_tokens.saturating_add(pt);
log.tokens.completion_tokens =
log.tokens.completion_tokens.saturating_add(ct);
}
None => {
log.tokens.completion_tokens =
log.tokens.completion_tokens.saturating_add(*tokens);
}
}
if let Some(c) = &cost {
prompt_usd_acc += c.prompt_usd;
completion_usd_acc += c.completion_usd;
cache_usd_acc += c.cache_usd;
any_priced_step = true;
}
idx += 1;
llm_idx += 1;
}
Episode::ToolCall { name, args, result } => {
let (output, error) = match result {
ToolResult::Ok { value } => (value.clone(), None),
ToolResult::Err { message } => (serde_json::Value::Null, Some(message.clone())),
};
log.steps.push(Step {
idx,
kind: StepKind::ToolCall,
name: Some(name.clone()),
prompt_tokens: None,
completion_tokens: None,
cost_usd: None,
input: args.clone(),
output,
error,
latency: Duration::ZERO, span_id: None,
});
idx += 1;
}
Episode::BusPublish { .. } | Episode::BusReceive { .. } => {
}
Episode::Completed => {
log.status = RunStatus::Completed;
log.finished_at = Some(Utc::now());
}
Episode::Failed { .. } => {
log.status = RunStatus::Failed;
log.finished_at = Some(Utc::now());
}
Episode::SummaryCheckpoint {
input_message_count,
summary_chars,
latency_ms,
tokens,
} => {
log.steps.push(Step {
idx,
kind: StepKind::SummaryCheckpoint,
name: Some("summary".into()),
prompt_tokens: None,
completion_tokens: None,
cost_usd: None,
input: serde_json::json!({
"input_message_count": input_message_count,
}),
output: serde_json::json!({
"summary_chars": summary_chars,
}),
error: None,
latency: Duration::from_millis(u64::from(*latency_ms)),
span_id: None,
});
log.tokens.completion_tokens = log.tokens.completion_tokens.saturating_add(*tokens);
idx += 1;
}
Episode::Ops(payload) => {
let kind_name = payload
.get("kind")
.and_then(|k| k.as_str())
.map(str::to_owned);
log.steps.push(Step {
idx,
kind: StepKind::OpsEvent,
name: kind_name,
prompt_tokens: None,
completion_tokens: None,
cost_usd: None,
input: payload.clone(),
output: serde_json::Value::Null,
error: None,
latency: Duration::ZERO,
span_id: None,
});
idx += 1;
}
Episode::MemoryRecall {
query,
k,
returned_fact_ids,
} => {
log.steps.push(Step {
idx,
kind: StepKind::MemoryRecall,
name: Some("recall".to_string()),
prompt_tokens: None,
completion_tokens: None,
cost_usd: None,
input: serde_json::json!({ "query": query, "k": k }),
output: serde_json::json!({ "returned_fact_ids": returned_fact_ids }),
error: None,
latency: Duration::ZERO,
span_id: None,
});
idx += 1;
}
_ => {}
}
}
if any_priced_step {
log.cost_estimate = Some(Cost::new(prompt_usd_acc, completion_usd_acc, cache_usd_acc));
}
log
}
fn step_cost(io: &LlmIo, prices: &PriceTable) -> Option<Cost> {
let provider = io.provider.as_deref()?;
let model = io.model.as_deref()?;
let pt = io.prompt_tokens?;
let ct = io.completion_tokens?;
let rates = prices.lookup(provider, model)?;
let cache_read = io.cache_read_tokens.unwrap_or(0);
let cache_creation = io.cache_creation_tokens.unwrap_or(0);
Some(rates.cost_for_cached(pt, ct, cache_read, cache_creation))
}
fn episode_cost(
provider: Option<&str>,
model: Option<&str>,
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
prices: &PriceTable,
) -> Option<Cost> {
let rates = prices.lookup(provider?, model?)?;
Some(rates.cost_for(prompt_tokens?, completion_tokens?))
}