use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::SystemTime;
use tower::Service;
use super::{
BudgetDimension, BudgetLedger, BudgetLedgerService, BudgetVerdict, CostCheckContext, CostRecordContext,
Enforcement, provider_of, user_id_of,
};
use crate::client::BoxFuture;
use crate::cost;
use crate::error::{LiterLlmError, Result};
use crate::tower::cost::observe_stream_usage;
use crate::tower::types::{LlmRequest, LlmResponse};
use crate::types::Usage;
struct LedgerCallMeta {
model: String,
provider: String,
tenant_id: Option<String>,
user_id: Option<String>,
}
impl LedgerCallMeta {
fn from_request(req: &LlmRequest) -> Self {
let model = req.model().unwrap_or("unknown").to_owned();
let provider = provider_of(&model).to_owned();
Self {
tenant_id: req.tenant_id().map(|t| t.as_ref().to_owned()),
user_id: user_id_of(req).map(str::to_owned),
model,
provider,
}
}
fn check_ctx(&self) -> CostCheckContext<'_> {
CostCheckContext {
model: &self.model,
provider: &self.provider,
tenant_id: self.tenant_id.as_deref(),
user_id: self.user_id.as_deref(),
api_key_id: None,
timestamp: SystemTime::now(),
}
}
fn record_ctx(&self, usd: f64, usage: &Usage) -> CostRecordContext<'_> {
CostRecordContext {
model: &self.model,
provider: &self.provider,
tenant_id: self.tenant_id.as_deref(),
user_id: self.user_id.as_deref(),
api_key_id: None,
cost_usd: usd,
tokens_in: usage.prompt_tokens,
tokens_out: usage.completion_tokens,
timestamp: SystemTime::now(),
}
}
fn cost_of(&self, usage: &Usage) -> Option<f64> {
cost::completion_cost(&self.model, usage.prompt_tokens, usage.completion_tokens)
}
}
fn budget_exceeded(reason: String, dimension: &BudgetDimension) -> LiterLlmError {
let model_field = match dimension {
BudgetDimension::Model(m) => Some(m.clone()),
_ => None,
};
LiterLlmError::BudgetExceeded {
message: reason,
model: model_field,
}
}
fn record_stream_on_completion<L: BudgetLedger>(
ledger: Arc<L>,
meta: LedgerCallMeta,
resp: LlmResponse,
) -> LlmResponse {
let LlmResponse::ChatStream(stream) = resp else {
return resp;
};
let wrapped = observe_stream_usage(stream, move |usage| {
let Some(usage) = usage else { return };
let Some(usd) = meta.cost_of(&usage) else {
return;
};
let record = async move {
let ctx = meta.record_ctx(usd, &usage);
ledger.record(&ctx).await;
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(record);
} else {
tracing::warn!(
"budget ledger: no Tokio runtime available to record streamed usage; spend was not recorded"
);
}
});
LlmResponse::ChatStream(wrapped)
}
impl<L, S> Service<LlmRequest> for BudgetLedgerService<L, S>
where
L: BudgetLedger,
S: Service<LlmRequest, Response = LlmResponse, Error = LiterLlmError> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Response = LlmResponse;
type Error = LiterLlmError;
type Future = BoxFuture<'static, Result<LlmResponse>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: LlmRequest) -> Self::Future {
let meta = LedgerCallMeta::from_request(&req);
let ledger = Arc::clone(&self.ledger);
let enforcement = self.enforcement;
let standby = self.inner.clone();
let mut inner = std::mem::replace(&mut self.inner, standby);
Box::pin(async move {
if enforcement == Enforcement::Hard
&& let BudgetVerdict::Reject { reason, dimension } = ledger.check(&meta.check_ctx()).await
{
return Err(budget_exceeded(reason, &dimension));
}
let resp = inner.call(req).await?;
if matches!(resp, LlmResponse::ChatStream(_)) {
return Ok(record_stream_on_completion(ledger, meta, resp));
}
if let Some(usage) = resp.usage()
&& let Some(usd) = meta.cost_of(usage)
{
let ctx = meta.record_ctx(usd, usage);
ledger.record(&ctx).await;
}
Ok(resp)
})
}
}