use chrono::{DateTime, Utc};
use klieo_core::ids::RunId;
use klieo_core::llm::{FinishReason, ToolCall};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RunStatus {
Running,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum StepKind {
LlmCall,
ToolCall,
SummaryCheckpoint,
OpsEvent,
MemoryRecall,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Step {
pub idx: u32,
pub kind: StepKind,
pub name: Option<String>,
#[serde(default)]
pub prompt_tokens: Option<u32>,
#[serde(default)]
pub completion_tokens: Option<u32>,
#[serde(default)]
pub cost_usd: Option<f64>,
pub input: serde_json::Value,
pub output: serde_json::Value,
pub error: Option<String>,
#[serde(with = "duration_millis")]
#[cfg_attr(feature = "schemars", schemars(with = "u64"))]
pub latency: Duration,
pub span_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct LlmIo {
pub prompt: String,
pub completion: String,
pub model: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub prompt_tokens: Option<u32>,
#[serde(default)]
pub completion_tokens: Option<u32>,
#[serde(default)]
pub cache_read_tokens: Option<u32>,
#[serde(default)]
pub cache_creation_tokens: Option<u32>,
#[serde(default)]
#[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
pub finish_reason: Option<FinishReason>,
#[serde(default)]
#[cfg_attr(feature = "schemars", schemars(with = "Vec<serde_json::Value>"))]
pub tool_calls: Vec<ToolCall>,
#[serde(default)]
pub request_fingerprint: Option<String>,
}
impl LlmIo {
pub fn new(prompt: impl Into<String>, completion: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
completion: completion.into(),
..Self::default()
}
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_cost(
mut self,
provider: impl Into<String>,
prompt_tokens: u32,
completion_tokens: u32,
) -> Self {
self.provider = Some(provider.into());
self.prompt_tokens = Some(prompt_tokens);
self.completion_tokens = Some(completion_tokens);
self
}
pub fn with_cache_tokens(mut self, cache_read_tokens: u32, cache_creation_tokens: u32) -> Self {
self.cache_read_tokens = Some(cache_read_tokens);
self.cache_creation_tokens = Some(cache_creation_tokens);
self
}
pub fn with_response_shape(
mut self,
finish_reason: FinishReason,
tool_calls: Vec<ToolCall>,
) -> Self {
self.finish_reason = Some(finish_reason);
self.tool_calls = tool_calls;
self
}
pub fn with_request_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
self.request_fingerprint = Some(fingerprint.into());
self
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Cost {
pub prompt_usd: f64,
pub completion_usd: f64,
#[serde(default)]
pub cache_usd: f64,
pub total_usd: f64,
}
impl Cost {
pub fn new(prompt_usd: f64, completion_usd: f64, cache_usd: f64) -> Self {
Self {
prompt_usd,
completion_usd,
cache_usd,
total_usd: prompt_usd + completion_usd + cache_usd,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RunLog {
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
pub run_id: RunId,
pub agent: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub status: RunStatus,
pub steps: Vec<Step>,
pub tokens: Usage,
pub cost_estimate: Option<Cost>,
}
mod duration_millis {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_millis() as u64)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let ms = u64::deserialize(d)?;
Ok(Duration::from_millis(ms))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use klieo_core::ids::RunId;
use std::time::Duration;
#[test]
fn llm_io_roundtrips_structured_fields() {
let io = LlmIo::new("p", "c")
.with_model("gpt-4o")
.with_response_shape(
FinishReason::ToolCalls,
vec![ToolCall::new(
"call_1",
"search",
serde_json::json!({"q": "x"}),
)],
)
.with_request_fingerprint("abc123");
let json = serde_json::to_string(&io).unwrap();
let back: LlmIo = serde_json::from_str(&json).unwrap();
assert_eq!(back, io);
assert_eq!(back.finish_reason, Some(FinishReason::ToolCalls));
assert_eq!(back.tool_calls.len(), 1);
assert_eq!(back.request_fingerprint.as_deref(), Some("abc123"));
}
#[test]
fn with_cost_sets_provider_and_token_split() {
let io = LlmIo::new("p", "c").with_cost("openai", 100, 50);
assert_eq!(io.provider.as_deref(), Some("openai"));
assert_eq!(io.prompt_tokens, Some(100));
assert_eq!(io.completion_tokens, Some(50));
}
#[test]
fn cache_tokens_default_to_none_and_set_via_builder() {
let bare = LlmIo::new("p", "c");
assert_eq!(bare.cache_read_tokens, None);
assert_eq!(bare.cache_creation_tokens, None);
let cached = LlmIo::new("p", "c").with_cache_tokens(100, 8);
assert_eq!(cached.cache_read_tokens, Some(100));
assert_eq!(cached.cache_creation_tokens, Some(8));
}
#[test]
fn old_record_without_new_fields_deserializes() {
let legacy = r#"{"prompt":"p","completion":"c","model":null}"#;
let io: LlmIo = serde_json::from_str(legacy).unwrap();
assert_eq!(io.prompt, "p");
assert_eq!(io.finish_reason, None);
assert!(io.tool_calls.is_empty());
assert_eq!(io.request_fingerprint, None);
}
#[test]
fn run_status_serialises_snake_case() {
let json = serde_json::to_string(&RunStatus::Running).unwrap();
assert_eq!(json, "\"running\"");
}
#[test]
fn step_kind_round_trip() {
for k in [StepKind::LlmCall, StepKind::ToolCall] {
let json = serde_json::to_string(&k).unwrap();
let back: StepKind = serde_json::from_str(&json).unwrap();
assert_eq!(k, back);
}
}
#[test]
fn step_round_trip() {
let step = Step {
idx: 0,
kind: StepKind::ToolCall,
name: Some("calc".into()),
prompt_tokens: Some(3),
completion_tokens: Some(9),
cost_usd: None,
input: serde_json::json!({"a": 1}),
output: serde_json::json!({"b": 2}),
error: None,
latency: Duration::from_millis(42),
span_id: Some("span-xyz".into()),
};
let json = serde_json::to_string(&step).unwrap();
let back: Step = serde_json::from_str(&json).unwrap();
assert_eq!(step.idx, back.idx);
assert_eq!(step.latency, back.latency);
assert_eq!(step.name, back.name);
}
#[test]
fn run_log_round_trip_empty_steps() {
let log = RunLog {
run_id: RunId::new(),
agent: "writer".into(),
started_at: Utc::now(),
finished_at: None,
status: RunStatus::Running,
steps: vec![],
tokens: Usage::default(),
cost_estimate: None,
};
let json = serde_json::to_string(&log).unwrap();
assert!(json.contains("\"steps\":[]"));
let _back: RunLog = serde_json::from_str(&json).unwrap();
}
#[test]
fn usage_default_is_zero() {
let u = Usage::default();
assert_eq!(u.prompt_tokens, 0);
assert_eq!(u.completion_tokens, 0);
}
#[test]
fn cost_round_trip() {
let c = Cost {
prompt_usd: 0.001,
completion_usd: 0.002,
cache_usd: 0.0004,
total_usd: 0.0034,
};
let json = serde_json::to_string(&c).unwrap();
let back: Cost = serde_json::from_str(&json).unwrap();
assert!((back.total_usd - 0.0034).abs() < 1e-9);
assert!((back.cache_usd - 0.0004).abs() < 1e-9);
}
#[test]
fn cost_deserializes_legacy_json_without_cache_field() {
let legacy = r#"{"prompt_usd":0.001,"completion_usd":0.002,"total_usd":0.003}"#;
let c: Cost = serde_json::from_str(legacy).unwrap();
assert!(c.cache_usd.abs() < 1e-12);
assert!((c.total_usd - 0.003).abs() < 1e-9);
}
}