use ares_store::run_history::{
BudgetAlert, LogLlmCallRequest, LogToolCallRequest, RunCost, RunHistoryStore,
};
use ares_llm::observability::{LlmCallRecord, ObservabilitySink, ToolCallRecord};
use chrono::Datelike;
use rust_decimal::Decimal;
#[cfg(feature = "postgres")]
use sqlx::PgPool;
#[cfg(not(feature = "postgres"))]
type PgPool = ();
use uuid::Uuid;
pub(crate) fn estimated_cost_usd(prompt_tokens: i64, completion_tokens: i64) -> Decimal {
let total = prompt_tokens + completion_tokens;
Decimal::new(total * 2, 6)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunCostAggregationRequest {
pub run_id: String,
pub tenant_id: String,
pub agent_name: String,
pub duration_ms: i64,
}
pub fn run_cost_aggregation_request(
run_id: &str,
tenant_id: &str,
agent_name: &str,
duration_ms: i64,
) -> RunCostAggregationRequest {
RunCostAggregationRequest {
run_id: run_id.to_string(),
tenant_id: tenant_id.to_string(),
agent_name: agent_name.to_string(),
duration_ms,
}
}
pub fn spawn_run_cost_aggregation(pool: PgPool, request: RunCostAggregationRequest) {
let obs = RunObservability {
run_id: request.run_id,
tenant_id: request.tenant_id,
agent_name: request.agent_name,
pool,
};
tokio::spawn(async move {
obs.aggregate_run_cost(request.duration_ms).await;
});
}
pub struct RunObservability {
pub run_id: String,
pub tenant_id: String,
pub agent_name: String,
pub pool: PgPool,
}
#[async_trait::async_trait]
impl ObservabilitySink for RunObservability {
async fn log_llm_call(&self, record: LlmCallRecord) {
let estimated = estimated_cost_usd(record.prompt_tokens, record.completion_tokens);
if let Err(e) = self.check_budget(estimated).await {
tracing::warn!(error = %e, run_id = %self.run_id, "Budget check failed");
}
let req = LogLlmCallRequest {
id: Uuid::new_v4().to_string(),
run_id: self.run_id.clone(),
tenant_id: self.tenant_id.clone(),
agent_name: self.agent_name.clone(),
step_index: record.step_index,
provider: record.provider,
model: record.model,
prompt_tokens: record.prompt_tokens,
completion_tokens: record.completion_tokens,
total_tokens: record.prompt_tokens + record.completion_tokens,
estimated_cost_usd: estimated,
latency_ms: record.latency_ms,
cached_tokens: record.cached_tokens,
total_time_ms: record.total_time_ms,
status: record.status,
error_message: None,
request_payload: None,
response_payload: None,
created_at: chrono::Utc::now().timestamp(),
};
let store = RunHistoryStore::new(&self.pool);
if let Err(e) = store.insert_llm_call(&req).await {
tracing::warn!(error = %e, run_id = %self.run_id, "Failed to log LLM call");
}
}
async fn log_tool_call(&self, record: ToolCallRecord) {
let req = LogToolCallRequest {
id: Uuid::new_v4().to_string(),
run_id: self.run_id.clone(),
tenant_id: self.tenant_id.clone(),
agent_name: self.agent_name.clone(),
step_index: record.step_index,
tool_name: record.tool_name,
tool_type: record.tool_type,
arguments: record.arguments,
result: record.result,
latency_ms: record.latency_ms,
status: record.status,
error_message: None,
created_at: chrono::Utc::now().timestamp(),
};
let store = RunHistoryStore::new(&self.pool);
if let Err(e) = store.insert_tool_call(&req).await {
tracing::warn!(error = %e, run_id = %self.run_id, "Failed to log tool call");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_cost_aggregation_request_preserves_run_identity() {
let req = run_cost_aggregation_request("run-1", "tenant-a", "agent-a", 42);
assert_eq!(req.run_id, "run-1");
assert_eq!(req.tenant_id, "tenant-a");
assert_eq!(req.agent_name, "agent-a");
assert_eq!(req.duration_ms, 42);
}
}
#[cfg(feature = "postgres")]
impl RunObservability {
pub async fn check_budget(&self, estimated_cost: Decimal) -> std::result::Result<(), String> {
let store = RunHistoryStore::new(&self.pool);
let budget = match store.get_tenant_budget(&self.tenant_id).await {
Ok(Some(b)) => b,
Ok(None) => return Ok(()), Err(e) => {
tracing::warn!(error = %e, "Failed to fetch tenant budget");
return Ok(());
}
};
let now = chrono::Utc::now();
let period_start = now
.date_naive()
.with_day(1)
.unwrap_or(now.date_naive())
.and_time(chrono::NaiveTime::MIN)
.and_utc()
.timestamp();
let period_end = now.timestamp();
let spent = match sqlx::query_scalar::<_, Decimal>(
"SELECT COALESCE(SUM(total_estimated_cost_usd), 0) FROM run_costs \
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3",
)
.bind(&self.tenant_id)
.bind(period_start)
.bind(period_end)
.fetch_one(&self.pool)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "Failed to compute spend");
return Ok(());
}
};
if spent + estimated_cost > budget.monthly_limit_usd {
tracing::warn!(
tenant_id = %self.tenant_id,
spent = %spent,
budget = %budget.monthly_limit_usd,
estimated = %estimated_cost,
"Tenant budget would be exceeded"
);
let alert = BudgetAlert {
id: Uuid::new_v4().to_string(),
tenant_id: self.tenant_id.clone(),
alert_type: "monthly_limit".to_string(),
current_spend_usd: spent,
limit_usd: budget.monthly_limit_usd,
threshold_pct: budget.alert_threshold_pct,
period_start,
period_end,
acknowledged: false,
acknowledged_by: None,
acknowledged_at: None,
created_at: now.timestamp(),
};
if let Err(e) = store.insert_budget_alert(&alert).await {
tracing::warn!(error = %e, "Failed to insert budget alert");
}
return Err("Tenant monthly budget exceeded".to_string());
}
Ok(())
}
pub async fn aggregate_run_cost(&self, duration_ms: i64) {
let store = RunHistoryStore::new(&self.pool);
let llm_calls = match store.get_llm_calls_for_run(&self.run_id).await {
Ok(calls) => calls,
Err(e) => {
tracing::warn!(error = %e, run_id = %self.run_id, "Failed to fetch LLM calls for cost aggregation");
return;
}
};
let tool_calls = match store.get_tool_calls_for_run(&self.run_id).await {
Ok(calls) => calls,
Err(e) => {
tracing::warn!(error = %e, run_id = %self.run_id, "Failed to fetch tool calls for cost aggregation");
return;
}
};
let total_prompt_tokens: i64 = llm_calls.iter().map(|c| c.prompt_tokens).sum();
let total_completion_tokens: i64 = llm_calls.iter().map(|c| c.completion_tokens).sum();
let total_estimated_cost: Decimal = llm_calls.iter().map(|c| c.estimated_cost_usd).sum();
let record = RunCost {
run_id: self.run_id.clone(),
tenant_id: self.tenant_id.clone(),
agent_name: self.agent_name.clone(),
total_llm_calls: llm_calls.len() as i32,
total_tool_calls: tool_calls.len() as i32,
total_prompt_tokens,
total_completion_tokens,
total_estimated_cost_usd: total_estimated_cost,
total_duration_ms: duration_ms,
created_at: chrono::Utc::now().timestamp(),
};
if let Err(e) = store.upsert_run_cost(&record).await {
tracing::warn!(error = %e, run_id = %self.run_id, "Failed to upsert run cost");
}
}
}