use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::config::models::request_ledger::RequestLedgerWriteFailure;
tokio::task_local! {
static REQUEST_LEDGER_FACTS: SharedRequestLedgerFacts;
}
pub type SharedRequestLedgerFacts = Arc<Mutex<RequestLedgerFacts>>;
#[derive(Debug, Clone, Default, Serialize)]
pub struct RequestLedgerFacts {
pub model: Option<String>,
pub provider: Option<String>,
pub deployment: Option<String>,
pub prompt_tokens: Option<i64>,
pub completion_tokens: Option<i64>,
pub total_tokens: Option<i64>,
pub cost: Option<f64>,
}
impl RequestLedgerFacts {
pub fn record_settlement(
&mut self,
provider: &str,
model: &str,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
total_tokens: Option<i64>,
cost: Option<f64>,
) {
self.provider = Some(provider.to_string());
self.model = Some(model.to_string());
if self.deployment.is_none() {
self.deployment = Some(provider.to_string());
}
if prompt_tokens.is_some() {
self.prompt_tokens = prompt_tokens;
}
if completion_tokens.is_some() {
self.completion_tokens = completion_tokens;
}
if total_tokens.is_some() {
self.total_tokens = total_tokens;
}
if cost.is_some() {
self.cost = cost;
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RequestLedgerRecord {
pub request_id: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub method: String,
pub endpoint: String,
pub model: Option<String>,
pub provider: Option<String>,
pub deployment: Option<String>,
pub status_code: i32,
pub terminal_status: String,
pub latency_ms: i64,
pub prompt_tokens: Option<i64>,
pub completion_tokens: Option<i64>,
pub total_tokens: Option<i64>,
pub cost: Option<f64>,
pub user_id: Option<String>,
pub api_key_id: Option<String>,
pub team_id: Option<String>,
}
#[async_trait::async_trait]
pub trait RequestLedgerWriter: Send + Sync {
async fn persist(&self, record: RequestLedgerRecord) -> Result<(), String>;
}
#[derive(Clone)]
pub struct RequestLedgerRuntime {
pub writer: Arc<dyn RequestLedgerWriter>,
pub write_failure: RequestLedgerWriteFailure,
}
pub async fn persist_with_policy(
writer: &dyn RequestLedgerWriter,
record: RequestLedgerRecord,
policy: RequestLedgerWriteFailure,
) -> Result<(), String> {
let request_id = record.request_id.clone();
match writer.persist(record).await {
Ok(()) => Ok(()),
Err(error) => match policy {
RequestLedgerWriteFailure::Continue => {
tracing::error!(
request_id = %request_id,
error = %error,
"request ledger persist failed; continuing"
);
Ok(())
}
RequestLedgerWriteFailure::Fail => {
tracing::error!(
request_id = %request_id,
error = %error,
"request ledger persist failed; failing request"
);
Err(error)
}
},
}
}
pub async fn scope_facts<F>(facts: SharedRequestLedgerFacts, future: F) -> F::Output
where
F: std::future::Future,
{
REQUEST_LEDGER_FACTS.scope(facts, future).await
}
pub fn current_facts() -> Option<SharedRequestLedgerFacts> {
REQUEST_LEDGER_FACTS.try_with(Arc::clone).ok()
}
pub fn record_current_settlement(
provider: &str,
model: &str,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
total_tokens: Option<i64>,
cost: Option<f64>,
) {
let Some(facts) = current_facts() else {
return;
};
apply_settlement(
&facts,
provider,
model,
prompt_tokens,
completion_tokens,
total_tokens,
cost,
);
}
pub fn apply_settlement(
facts: &SharedRequestLedgerFacts,
provider: &str,
model: &str,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
total_tokens: Option<i64>,
cost: Option<f64>,
) {
let mut facts = match facts.lock() {
Ok(facts) => facts,
Err(poisoned) => poisoned.into_inner(),
};
facts.record_settlement(
provider,
model,
prompt_tokens,
completion_tokens,
total_tokens,
cost,
);
}
pub fn snapshot_facts(facts: &SharedRequestLedgerFacts) -> RequestLedgerFacts {
match facts.lock() {
Ok(facts) => facts.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct FailingWriter;
#[async_trait::async_trait]
impl RequestLedgerWriter for FailingWriter {
async fn persist(&self, _record: RequestLedgerRecord) -> Result<(), String> {
Err("disk full".to_string())
}
}
fn sample_record() -> RequestLedgerRecord {
RequestLedgerRecord {
request_id: "req-1".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
method: "POST".to_string(),
endpoint: "/v1/chat/completions".to_string(),
model: Some("gpt-4".to_string()),
provider: Some("openai".to_string()),
deployment: Some("openai".to_string()),
status_code: 200,
terminal_status: "completed".to_string(),
latency_ms: 12,
prompt_tokens: Some(3),
completion_tokens: Some(5),
total_tokens: Some(8),
cost: Some(0.01),
user_id: None,
api_key_id: Some("key-1".to_string()),
team_id: None,
}
}
#[test]
fn serialized_record_has_no_body_or_secret_fields() {
let json = serde_json::to_value(sample_record()).expect("record serializes");
let keys: Vec<&str> = json
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
for forbidden in [
"body",
"prompt",
"authorization",
"header",
"secret",
"api_key",
"raw_key",
] {
assert!(
!keys.contains(&forbidden),
"{forbidden} must not be a ledger column, got {keys:?}"
);
}
assert!(keys.contains(&"api_key_id"));
assert!(keys.contains(&"request_id"));
}
#[tokio::test]
async fn continue_policy_logs_and_succeeds() {
persist_with_policy(
&FailingWriter,
sample_record(),
RequestLedgerWriteFailure::Continue,
)
.await
.expect("continue must not fail the caller");
}
#[tokio::test]
async fn fail_policy_returns_the_write_error() {
let error = persist_with_policy(
&FailingWriter,
sample_record(),
RequestLedgerWriteFailure::Fail,
)
.await
.expect_err("fail must surface the write error");
assert_eq!(error, "disk full");
}
#[test]
fn settlement_does_not_store_authorization_or_bodies() {
let facts = SharedRequestLedgerFacts::new(Mutex::new(RequestLedgerFacts::default()));
apply_settlement(
&facts,
"openai",
"gpt-4",
Some(1),
Some(2),
Some(3),
Some(0.2),
);
let snapshot = snapshot_facts(&facts);
let json = serde_json::to_value(&snapshot).expect("facts serialize");
let text = json.to_string().to_lowercase();
assert!(!text.contains("authorization"));
assert!(!text.contains("bearer"));
assert!(!text.contains("sk-"));
assert_eq!(snapshot.model.as_deref(), Some("gpt-4"));
}
}