fix(proxy): show committed spend in the remaining budget gauges (#49199)

* fix(proxy): report live key and team spend to budget gauges within the key cache TTL

* fix(prometheus): carry live spend for the user, org and customer budget gauges too

The remaining budget gauges for users, orgs and customers read the same cached rows as the key and
team gauges, so they read high for about a minute after a charge. Auth now carries the live counter
for each budgeted entity in one snapshot that only the gauges read, floored at the spend auth loaded.
The x-litellm-key-spend header and logging spend fields keep their current values.

* fix(prometheus): show committed spend in the remaining budget gauges

Reads twin counters that take only charged cost, so a worst-case budget hold
never shows as spent and a request that finishes last cannot set a gauge back.
The twins exist only while the Prometheus logger is registered, and every path
that resets a spend counter drops them. Failed requests now set the org gauge
from the carried org row and committed spend, not the cached org row.

* fix(prometheus): count a charge the cached customer row already holds once

* fix(prometheus): keep committed spend only for budgeted entities and drop it on spend resets

Auth now reads committed spend through the admission MGET and names the counters a charge
may move, so the master key and unbudgeted entities get no twin. Organization spend edits
and the global spend reset drop their twins, and a long request no longer reads as charged.

* fix(prometheus): read the committed org spend on failed requests without the auth object

* test(prometheus): cover a failed request with no carried org

* fix(prometheus): move the committed counters for charges with no auth on the row

A billed background response is charged from its creator's stored attribution, after the request's auth is gone. Its charge moved the live counters but not the committed ones, so every gauge read one charge behind. It now moves the committed counters that already exist, and its own gauge adds its cost to them.

* fix(prometheus): keep the gauges right for pass-through rows, overlapping charges and slow DB spend writes

Pass-through rows carry the auth object, so a cold committed counter gets seeded there too. The gauges read a
charge still waiting in the post-call pipeline as not counted, whatever other charges reached the counter. Committed
counters outlive two DB spend writes. Cold seeds read the DB once per worker, and the global spend reset reads keys
and teams in bounded pages.

* fix(prometheus): count a charged failure's own cost in the org gauge

A stream cut mid-response still charges for what it delivered, and that charge is usually still queued when the failure logs. The failure path passed a cost of 0, so the org gauge left that charge out until the worker's next request

* fix(prometheus): skip twins for window-named ids, drop twins on delete, keep auth out of PostHog

* fix(prometheus): keep the auth object off pass-through rows and drop user twins on every user delete

Pass-through rows now carry only the committed spend in litellm_params, so loggers that dump the row's metadata never get the auth object. Every user delete path, SCIM and bulk delete included, drops the deleted users' committed spend twins

* fix(prometheus): keep committed spend values out of logger dumps of pass-through rows

CommittedBudgetSpend fields are repr=False, so Athina's payload, Sentry breadcrumbs and debug logs that str() a pass-through row's litellm_params show no spend values

* fix(prometheus): keep the gauge fallback on a slow committed read and refresh committed twin TTLs on cluster

The success-path committed read now gets half the per-request budget, so a slow Redis falls back to stored spend before the outer timeout cancels the gauges. Post-call charges refresh a committed twin's TTL on Redis Cluster too, so a twin never expires while its charges are still on their way to the DB. Also applies cargo fmt to the PR's Rust files

* fix(proxy): key the global reset's committed-spend page queries so the Prisma span check reads them

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
zoveki 2026-11-09 12:05:37 -08:00 • committed by GitHub
parent 60d6797983
commit abe3b11f7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2846 additions and 121 deletions

View file

@ -55,7 +55,9 @@ mod _native {
#[pymodule_export]
use crate::redis::store::sensitive_route_pins::NativeSensitiveRoutePins;
#[pymodule_export]
use crate::redis::store::spend_counters::{NativeSpendCounters, PySpendRead};
use crate::redis::store::spend_counters::{
NativeSpendCounters, PyPostCallSettlement, PySpendRead,
};
#[pymodule_export]
use crate::redis::store::{
NativePending, NativeRedisStoreError, router_cooldowns::NativeRouterCooldowns,
@ -154,6 +156,7 @@ mod tests {
"NativeSensitiveRoutePins",
"NativeRedisStoreError",
"NativeSpendCounters",
"PostCallSettlement",
"SpendRead",
"LocalSizes",
"native_redis_breaker_states",

View file

@ -2,12 +2,12 @@ use std::{collections::HashMap, sync::Arc};
use litellm_redis_store::{
Number,
spend_counters::{LostSeed, Reading, SpendCounter, SpendCounters},
spend_counters::{LostSeed, PostCallSettlement, Reading, SpendCounter, SpendCounters},
};
use pyo3::prelude::*;
use super::{
NativePending, PyData, PyNumber, kind_error, pending, redis_async, remote,
NativePending, PyData, PyNumber, duration, kind_error, pending, redis_async, remote,
request::{NativeRequestRedis, NativeSend},
ttl, unwrap_all,
};
@ -29,6 +29,18 @@ impl PySpendRead {
}
}
/// Whether every increment one `increment_many_post_call` declared has its reply.
#[pyclass(frozen, name = "PostCallSettlement")]
pub(crate) struct PyPostCallSettlement(PostCallSettlement);
#[pymethods]
impl PyPostCallSettlement {
#[getter]
fn settled(&self) -> bool {
self.0.is_settled()
}
}
fn increments(increments: Vec<(PyData<SpendCounter>, PyNumber)>) -> Vec<(SpendCounter, Number)> {
increments
.into_iter()
@ -73,6 +85,12 @@ impl NativeSpendCounters {
self.0.redis_ttl().map(|ttl| ttl.as_secs_f64())
}
/// The least TTL a Redis write gives a committed spend twin.
fn set_committed_ttl(&self, ttl_seconds: f64) -> PyResult<()> {
self.0.set_committed_ttl(duration(ttl_seconds)?);
Ok(())
}
/// `("redis" | "memory", value)`: where the answer came from, a Redis miss included.
fn read<'py>(
&self,
@ -125,14 +143,15 @@ impl NativeSpendCounters {
})
}
/// Declares the increments on the request's post-call batch; `False` when there is no Redis.
/// Declares the increments on the request's post-call batch; `None` when there is no Redis.
fn increment_many_post_call(
&self,
request: &NativeRequestRedis,
increments: Vec<(PyData<SpendCounter>, PyNumber)>,
) -> bool {
) -> Option<PyPostCallSettlement> {
self.0
.increment_many_post_call(&request.0, &self::increments(increments))
.map(PyPostCallSettlement)
}
fn set_max<'py>(

View file

@ -1,5 +1,5 @@
- One folder per Redis family (`router_cooldowns/`, `router_usage/`, `spend_counters/`, `auth_cache/`, `rate_limits/`), each with `mod.rs` (the store and its ops), `keys.rs` (key types and spellings) and `types.rs` (the family's values, with the `FromValue` / `IntoValue` codecs for what it stores in Redis). It is the only place that family's keys, value codec and scripts are written, and it takes domain ids, never keys. Shared pieces live once at the crate root: `Error` and `ErrorKind` (`error.rs`), `Memory` and `Clock` (`memory.rs`), `Pending` (`request.rs`), `Remote` and its attach-once `RemoteSlot`, which knows nothing of memory (`remote.rs`), `DualCache` (`dual_cache/`) and the Python codecs (`codec.rs`, `value.rs`)
- An op declared on a `Send` returns `Pending<T>`, a lazy future built when the op is declared. An op that only means something on Redis (an `MGET` the caller falls back from, a script) returns `None` without one, and an op that replaced a `DualCache` call answers from memory. A post-call method returns `false` when there was no Redis to declare on
- An op declared on a `Send` returns `Pending<T>`, a lazy future built when the op is declared. An op that only means something on Redis (an `MGET` the caller falls back from, a script) returns `None` without one, and an op that replaced a `DualCache` call answers from memory. A post-call method returns `false` when there was no Redis to declare on, or `None` when it returns a settlement the caller waits on, like `SpendCounterStore::increment_many_post_call`
- A store that sends a pipeline of its own uses `RedisBatch::pipelined`, never `Pipeline` and raw `Value`s
- Python and Rust pods share these keys during a rolling deploy, so a module writes the bytes `main`'s Python writes and reads everything `main` reads. Python formats go through `litellm-python-compat` (`repr`, `literal_eval`, `json`), never a hand-rolled codec
- A family holds one `DualCache` per `DualCache` its Python held on `main`, and each `DualCache` method is the Python method it names, so a family op that was a `DualCache` call is one call here. Where `main`'s Python reached past `DualCache` to `in_memory_cache` or `redis_cache`, the family does the same through `memory()` and `redis()`, which keeps that reach visible. Caches a family's Python attached to one `RedisCache` share one `Arc<RemoteSlot>`. Only a family that batch-reads builds its cache with `DualCache::batch_reading` from a `TierPolicy` the host passes in, and the Python host fills it from what `main` reads from the environment or `litellm` globals (`litellm/rust_bridge/host/settings.py`, `router_tiers`). Callers pass a `Remote` (or none), never a cache. Redis-only families are plain functions over `&RedisClient`

View file

@ -1,6 +1,9 @@
/// One budget's spend counter. `parse` and `key` round-trip `main`'s `spend:*` keys exactly.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SpendCounter {
/// The charges-only twin of a key, team, user, org or end user lifetime counter, which budget
/// holds never touch: `spend:committed:` and the twin's key past its `spend:`.
Committed(Box<SpendCounter>),
Key {
token: String,
window: Option<String>,
@ -33,6 +36,9 @@ impl SpendCounter {
None => format!("spend:{scope}:{id}"),
};
match self {
Self::Committed(counter) => {
format!("spend:committed:{}", &counter.key()["spend:".len()..])
}
Self::Key { token, window } => windowed("key", token, window),
Self::Team { team_id, window } => windowed("team", team_id, window),
Self::TeamMember { user_id, team_id } => {
@ -51,6 +57,16 @@ impl SpendCounter {
pub fn parse(key: &str) -> Result<Self, NotASpendCounter> {
let invalid = || NotASpendCounter(key.to_owned());
let rest = key.strip_prefix("spend:").ok_or_else(invalid)?;
if let Some(twin) = rest.strip_prefix("committed:") {
return match Self::parse(&format!("spend:{twin}")).map_err(|_| invalid())? {
counter @ (Self::Key { window: None, .. }
| Self::Team { window: None, .. }
| Self::User(_)
| Self::Org(_)
| Self::EndUser(_)) => Ok(Self::Committed(Box::new(counter))),
_ => Err(invalid()),
};
}
let (scope, id) = rest.split_once(':').ok_or_else(invalid)?;
let windowed = || match id.rsplit_once(":window:") {
Some((id, window)) if !window.is_empty() => (id.to_owned(), Some(window.to_owned())),

View file

@ -1,13 +1,19 @@
mod keys;
mod types;
use std::{sync::Arc, time::Duration};
use std::{
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use litellm_python_compat::repr::to_str;
use litellm_redis_client::{Declared, Expiry, RedisBatch, commands};
pub use self::keys::{NotASpendCounter, SpendCounter};
pub use self::types::{LostSeed, Reading};
pub use self::types::{LostSeed, PostCallSettlement, Reading};
use crate::{
Error, Number, Pending, RequestRedis, Send,
dual_cache::{DualCache, MemorySettings},
@ -31,6 +37,7 @@ const SET_MAX: &str = concat!(
/// `DualCache` to its Redis and its memory.
pub struct SpendCounters {
cache: DualCache<Number>,
committed_ttl_millis: AtomicU64,
}
impl SpendCounters {
@ -45,6 +52,33 @@ impl SpendCounters {
MemorySettings::default(),
now,
),
committed_ttl_millis: AtomicU64::new(0),
}
}
/// The least TTL a Redis write gives a [`SpendCounter::Committed`] twin, so it outlives the DB
/// write that holds its charges and its next seed never reads a total still missing them.
pub fn set_committed_ttl(&self, ttl: Duration) {
let millis = u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
self.committed_ttl_millis.store(millis, Ordering::Relaxed);
}
fn ttl(&self, counter: &SpendCounter, default_ttl: Duration) -> Duration {
match counter {
SpendCounter::Committed(_) => default_ttl.max(Duration::from_millis(
self.committed_ttl_millis.load(Ordering::Relaxed),
)),
_ => default_ttl,
}
}
/// A committed twin's expiry moves out on every charge, on a cluster too, so it never expires
/// while a charge it holds is still on its way to the DB.
fn post_call_expiry(&self, counter: &SpendCounter, default_ttl: Duration) -> Expiry {
let ttl = self.ttl(counter, default_ttl);
match counter {
SpendCounter::Committed(_) => Expiry::Refresh(ttl),
_ => Expiry::KeepExisting(ttl),
}
}
@ -127,7 +161,7 @@ impl SpendCounters {
let increment = commands::incr_by_float(
&key,
amount.into(),
Some(Expiry::Refresh(remote.default_ttl)),
Some(Expiry::Refresh(self.ttl(counter, remote.default_ttl))),
);
match remote.client.send(increment).await {
Ok(total) => {
@ -159,7 +193,7 @@ impl SpendCounters {
pipeline.add(commands::incr_by_float(
&counter.key(),
(*amount).into(),
Some(Expiry::Refresh(remote.default_ttl)),
Some(Expiry::Refresh(self.ttl(counter, remote.default_ttl))),
))
});
let totals = Declared::all(totals.collect::<Vec<_>>()).await;
@ -175,15 +209,14 @@ impl SpendCounters {
}
/// `increment_many` on the request's post-call batch. Each total reaches memory once the batch
/// goes out, and a counter whose increment failed is dropped everywhere. `false` without Redis.
/// goes out, and a counter whose increment failed is dropped everywhere. `None` without Redis,
/// else a settlement that reports when every reply is back, whoever flushed the batch.
pub fn increment_many_post_call(
self: &Arc<Self>,
request: &RequestRedis,
increments: &[(SpendCounter, Number)],
) -> bool {
let Some(remote) = self.cache.redis().get() else {
return false;
};
) -> Option<PostCallSettlement> {
let remote = self.cache.redis().get()?;
let batch = Send::PostCall(request).batch(&remote.client);
let declared = increments
.iter()
@ -191,12 +224,14 @@ impl SpendCounters {
let total = batch.add(commands::incr_by_float(
&counter.key(),
(*amount).into(),
Some(Expiry::KeepExisting(remote.default_ttl)),
Some(self.post_call_expiry(counter, remote.default_ttl)),
));
(counter.clone(), total)
})
.collect::<Vec<_>>();
let store = Arc::clone(self);
let settled = Arc::new(AtomicBool::new(false));
let settlement = PostCallSettlement(Arc::clone(&settled));
request.spawn(async move {
for (counter, total) in declared {
match total.settled().await {
@ -215,8 +250,9 @@ impl SpendCounters {
}
}
}
settled.store(true, Ordering::Release);
});
true
Some(settlement)
}
/// `_repair_stale_spend_counter`: raise memory and Redis to `spend`, never lowering either.
@ -243,7 +279,7 @@ impl SpendCounters {
};
let args = (
to_str(&spend.to_value()),
remote.default_ttl.as_secs().to_string(),
self.ttl(counter, remote.default_ttl).as_secs().to_string(),
);
let stored: Option<f64> = remote
.client
@ -276,8 +312,9 @@ impl SpendCounters {
})
})?);
};
let ttl = self.ttl(counter, *default_ttl);
let current = if client
.send(commands::set_nx(&key, spend.to_json(), *default_ttl))
.send(commands::set_nx(&key, spend.to_json(), ttl))
.await?
{
spend
@ -290,7 +327,7 @@ impl SpendCounters {
let increment = commands::incr_by_float(
&key,
spend.into(),
Some(Expiry::KeepExisting(*default_ttl)),
Some(Expiry::KeepExisting(ttl)),
);
Number::Float(client.send(increment).await?)
}
@ -313,7 +350,7 @@ impl SpendCounters {
let Some(remote) = self.cache.redis().get() else {
return Ok(());
};
let ttl = ttl.unwrap_or(remote.default_ttl);
let ttl = ttl.unwrap_or_else(|| self.ttl(counter, remote.default_ttl));
remote
.client
.send(commands::set(&key, spend.to_json(), ttl))
@ -334,7 +371,10 @@ impl SpendCounters {
};
Ok(remote
.client
.send(commands::expire(&counter.key(), remote.default_ttl))
.send(commands::expire(
&counter.key(),
self.ttl(counter, remote.default_ttl),
))
.await?)
}

View file

@ -1,3 +1,8 @@
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use litellm_python_compat::Value;
use super::keys::SpendCounter;
@ -32,6 +37,17 @@ impl Reading {
}
}
/// One `increment_many_post_call`: settled once every increment it declared has its reply and
/// memory mirrors it, whether `flush_post` or the post-call deadline sent the batch.
#[derive(Clone, Debug)]
pub struct PostCallSettlement(pub(super) Arc<AtomicBool>);
impl PostCallSettlement {
pub fn is_settled(&self) -> bool {
self.0.load(Ordering::Acquire)
}
}
/// Who wins when a seed's `SET NX` lost and the winner's value is gone by the time it is read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LostSeed {

View file

@ -16,6 +16,7 @@ use support::{COOLDOWN_POLICY, connect, connect_observed, remote, within};
use tokio::runtime::Handle;
const TTL: Duration = Duration::from_secs(60);
const COMMITTED_TTL: Duration = Duration::from_secs(300);
const LONG_DEADLINE: Duration = Duration::from_secs(60);
const READS: [(&str, Number); 4] = [
@ -45,6 +46,10 @@ fn team(id: &str) -> SpendCounter {
}
}
fn committed(counter: SpendCounter) -> SpendCounter {
SpendCounter::Committed(Box::new(counter))
}
fn store(client: Option<RedisClient>) -> Arc<SpendCounters> {
Arc::new(SpendCounters::new(client.map(remote)))
}
@ -114,6 +119,26 @@ fn a_reading_names_where_it_was_answered(
"spend:model_access_group:gpt-group"
)]
#[case(SpendCounter::ShadowEval("job-7".to_owned()), "spend:shadow_eval:job-7")]
#[case::committed_key(
committed(SpendCounter::Key {
token: "88dc28d0".to_owned(),
window: None,
}),
"spend:committed:key:88dc28d0"
)]
#[case::committed_team(committed(team("team-a")), "spend:committed:team:team-a")]
#[case::committed_user(
committed(SpendCounter::User("user@example.com".to_owned())),
"spend:committed:user:user@example.com"
)]
#[case::committed_org(
committed(SpendCounter::Org("org-1".to_owned())),
"spend:committed:org:org-1"
)]
#[case::committed_end_user(
committed(SpendCounter::EndUser("customer é 1".to_owned())),
"spend:committed:end_user:customer é 1"
)]
fn every_counter_formats_and_parses_back_to_mains_key(
#[case] counter: SpendCounter,
#[case] key: &str,
@ -128,6 +153,11 @@ fn every_counter_formats_and_parses_back_to_mains_key(
#[case("spend:unknown:abc")]
#[case("spend:team_member:no-team")]
#[case("spend:")]
#[case::committed_window("spend:committed:key:abc:window:1d")]
#[case::committed_tag("spend:committed:tag:prod")]
#[case::committed_team_member("spend:committed:team_member:user:team-a")]
#[case::committed_twice("spend:committed:committed:key:abc")]
#[case::committed_nothing("spend:committed:")]
fn keys_outside_the_spend_counter_keyspace_are_refused(#[case] key: &str) {
assert!(SpendCounter::parse(key).is_err(), "{key}");
}
@ -307,7 +337,7 @@ async fn a_post_call_increment_from_a_thread_without_a_runtime_lands_in_redis_an
.expect("a Python thread has no Tokio context, and declaring there must not panic");
request.flush_post().await;
assert!(declared);
assert!(declared.is_some());
let mirrored =
litellm_redis_testing::eventually(async || store.get_local(&counter).unwrap()).await;
assert_eq!(mirrored, Number::Float(3.5));
@ -331,9 +361,12 @@ async fn post_call_increments_wait_for_the_post_batch_then_land_in_memory(
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let counter = team("t");
assert!(store.increment_many_post_call(&request, &[(counter.clone(), Number::Float(3.5))]));
let settlement = store
.increment_many_post_call(&request, &[(counter.clone(), Number::Float(3.5))])
.unwrap();
request.flush_pre().await;
assert!(!settlement.is_settled());
assert!(trips.seen().is_empty());
assert_eq!(
raw.send(commands::get::<String>(&counter.key()))
@ -345,6 +378,7 @@ async fn post_call_increments_wait_for_the_post_batch_then_land_in_memory(
request.flush_post().await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(settlement.is_settled());
let increment = match topology {
Topology::Standalone => Operation::Pipeline { ops: 1 },
Topology::Cluster => Operation::Command("INCRBYFLOAT"),
@ -557,13 +591,132 @@ async fn a_failed_post_call_increment_drops_the_counter_from_memory() {
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let counter = team("t");
store.increment(&counter, Number::Float(1.0)).await.unwrap();
assert!(store.increment_many_post_call(&request, &[(counter.clone(), Number::Float(1.0))]));
let settlement = store
.increment_many_post_call(&request, &[(counter.clone(), Number::Float(1.0))])
.unwrap();
deployment.stop().await.unwrap();
request.flush_post().await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(store.get_local(&counter).unwrap(), None);
assert!(settlement.is_settled());
}
#[rstest]
#[tokio::test]
async fn a_post_call_settlement_settles_when_the_deadline_sends_the_batch(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let raw = connect(&deployment);
let store = store(Some(connect(&deployment)));
let request = RequestRedis::new(Handle::current(), Duration::from_millis(50));
let counter = committed(team("t"));
let settlement = store
.increment_many_post_call(&request, &[(counter.clone(), Number::Float(2.5))])
.unwrap();
assert!(!settlement.is_settled());
litellm_redis_testing::eventually(async || settlement.is_settled().then_some(())).await;
assert_eq!(
raw.send(commands::get::<String>(&counter.key()))
.await
.unwrap(),
Some("2.5".to_owned())
);
}
#[rstest]
#[case::committed_twin(committed, 300)]
#[case::enforcement_counter(std::convert::identity, 60)]
#[tokio::test]
async fn every_write_gives_a_committed_twin_the_committed_ttl_and_other_counters_the_redis_ttl(
#[case] counter_of: fn(SpendCounter) -> SpendCounter,
#[case] ttl_seconds: u64,
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let raw = connect(&deployment);
let store = store(Some(connect(&deployment)));
store.set_committed_ttl(COMMITTED_TTL);
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let [
incremented,
pipelined,
post_call,
seeded,
repaired,
refreshed,
] = ["a", "b", "c", "d", "e", "f"].map(|id| counter_of(team(id)));
raw.send(commands::set(
&refreshed.key(),
"1.0",
Duration::from_secs(5),
))
.await
.unwrap();
store
.increment(&incremented, Number::Float(1.0))
.await
.unwrap();
store
.increment_many(&[(pipelined.clone(), Number::Float(1.0))])
.await
.unwrap();
let settlement = store
.increment_many_post_call(&request, &[(post_call.clone(), Number::Float(1.0))])
.unwrap();
request.flush_post().await;
store
.seed(&seeded, Number::Float(1.0), LostSeed::Answer)
.await
.unwrap();
store.set_max(&repaired, Number::Float(1.0)).await.unwrap();
assert!(store.refresh_ttl(&refreshed).await.unwrap());
assert!(settlement.is_settled());
let remaining =
async |counter: &SpendCounter| match raw.send(commands::ttl(&counter.key())).await.unwrap()
{
Ttl::Expires(remaining) => remaining,
other => panic!("{} has no TTL: {other:?}", counter.key()),
};
assert!(within(remaining(&incremented).await, ttl_seconds));
assert!(within(remaining(&pipelined).await, ttl_seconds));
assert!(within(remaining(&post_call).await, ttl_seconds));
assert!(within(remaining(&seeded).await, ttl_seconds));
assert!(within(remaining(&repaired).await, ttl_seconds));
assert!(within(remaining(&refreshed).await, ttl_seconds));
}
#[rstest]
#[tokio::test]
async fn a_post_call_charge_moves_a_committed_twins_expiry_out_to_the_committed_ttl(
#[values(Topology::Standalone, Topology::Cluster)] topology: Topology,
) {
let deployment = RedisDeployment::start(topology).await.unwrap();
let raw = connect(&deployment);
let store = store(Some(connect(&deployment)));
store.set_committed_ttl(COMMITTED_TTL);
let request = RequestRedis::new(Handle::current(), LONG_DEADLINE);
let counter = committed(team("t"));
raw.send(commands::set(&counter.key(), "1.0", Duration::from_secs(5)))
.await
.unwrap();
let settlement = store
.increment_many_post_call(&request, &[(counter.clone(), Number::Float(1.0))])
.unwrap();
request.flush_post().await;
assert!(settlement.is_settled());
match raw.send(commands::ttl(&counter.key())).await.unwrap() {
Ttl::Expires(remaining) => assert!(within(remaining, COMMITTED_TTL.as_secs())),
other => panic!("{} has no TTL: {other:?}", counter.key()),
}
}
#[tokio::test]
@ -592,7 +745,11 @@ async fn without_redis_counters_live_in_memory() {
store.read(&counter).await.unwrap(),
Reading::Memory(Some(Number::Float(7.0)))
);
assert!(!store.increment_many_post_call(&request, &[(counter.clone(), Number::Int(1))]));
assert!(
store
.increment_many_post_call(&request, &[(counter.clone(), Number::Int(1))])
.is_none()
);
assert!(
store
.read_many(RedisSend::PreCall(&request), &[counter])

View file

@ -452,6 +452,13 @@ class NativePending(Protocol[_T_co]):
def resolve(self) -> Awaitable[_T_co]: ...
class PostCallSettlement(Protocol):
"""Post-call writes declared together: settled once every reply is back, whoever sent their batch."""
@property
def settled(self) -> bool: ...
class NativeBatchResult(Generic[_T]):
"""Awaitable handle for a read declared on the request's native pre-call batch; awaiting it runs the
native flush hooks, flushes that batch, then answers from what it read."""
@ -526,6 +533,7 @@ class RequestRedisBatches:
"_native_misses",
"_native_send_factory",
"_post_call",
"_post_call_settlements",
"post_call_deadline",
"prefetched",
)
@ -538,6 +546,7 @@ class RequestRedisBatches:
) -> None:
self._batches: Final[dict[object, RedisBatch]] = {} # mutable-ok: lazily filled per backend
self._post_call: Final[dict[object, RedisBatch]] = {} # mutable-ok: lazily filled per backend
self._post_call_settlements: tuple[PostCallSettlement, ...] = ()
self.post_call_deadline: Final = post_call_deadline
self._native_factory: Final = native_factory
self._native_send_factory: Final = native_send_factory
@ -641,6 +650,14 @@ class RequestRedisBatches:
if not any(batch.pending for batch in self._post_call.values()):
_open_post_call.discard(self)
def track_post_call_settlement(self, settlement: PostCallSettlement) -> None:
"""Post-call writes a later reader in this request must know have landed, such as its committed spend."""
self._post_call_settlements = (*self._post_call_settlements, settlement)
@property
def post_call_settlements(self) -> tuple[PostCallSettlement, ...]:
return self._post_call_settlements
@property
def batches(self) -> tuple[RedisBatch, ...]:
return tuple(self._batches.values())

View file

@ -1941,6 +1941,7 @@ SPEND_ROLLUP_LOCK_TIMEOUT_MS: Final = max(1, get_env_int("SPEND_ROLLUP_LOCK_TIME
SPEND_LOG_QUEUE_SIZE_THRESHOLD: Final = get_env_int("SPEND_LOG_QUEUE_SIZE_THRESHOLD", 100)
SPEND_LOG_QUEUE_MAX_BYTES: Final = max(1, get_env_int("SPEND_LOG_QUEUE_MAX_BYTES", 64000000))
SPEND_LOG_QUEUE_POLL_INTERVAL: Final = get_env_float("SPEND_LOG_QUEUE_POLL_INTERVAL", 2.0)
SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS: Final = 30.0
RESPONSES_SESSION_LOOKUP_MAX_ATTEMPTS: Final = max(1, get_env_int("RESPONSES_SESSION_LOOKUP_MAX_ATTEMPTS", 3))
RESPONSES_SESSION_LOOKUP_RETRY_INTERVAL: Final = get_env_float("RESPONSES_SESSION_LOOKUP_RETRY_INTERVAL", 0.2)
SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE: Final = get_env_int("SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE", 10000)

View file

@ -21,6 +21,7 @@ from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
from litellm._internal_context import with_service_target
from litellm._logging import print_verbose, verbose_logger
from litellm.caching.redis_batch import active_request_redis_batches
from litellm.constants import (
AUTH_OBJECTS_TARGET,
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
@ -75,8 +76,10 @@ from litellm.types.integrations.prometheus import (
sanitize_prometheus_label_value,
validate_prometheus_deployment_and_latency_caller_identity,
)
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth, committed_budget_spend_of_row
from litellm.types.proxy.carried_budget_state import (
CommittedBudgetSpend,
EntitySpendCounterKeys,
KeyBudgetSnapshot,
OrgBudgetSnapshot,
TeamBudgetSnapshot,
@ -281,6 +284,11 @@ def _logged_fields(value: object) -> Mapping[str, object]:
return _NO_LOGGED_FIELDS
def _response_cost_of(logged: Mapping[str, object]) -> float:
cost: Final = logged.get("response_cost")
return float(cost) if isinstance(cost, (int, float)) else 0.0
_GOOGLE_PASS_THROUGH_PROVIDERS: Final = frozenset(
(litellm.LlmProviders.GEMINI.value, litellm.LlmProviders.VERTEX_AI.value)
)
@ -365,6 +373,104 @@ def _get_budget_metrics_per_request_timeout() -> float:
return parsed
def _stored_spend(value: object) -> float:
return float(value) if isinstance(value, (int, float)) else 0.0
_OWN_CHARGE_MATCH_RATIO: Final = 1 - 1e-9
def _committed_floor(stored: float, committed: float | None) -> float:
return stored if committed is None else max(stored, committed)
def _customer_spend_after_request(cached_spend: float, committed_spend: float | None, response_cost: float) -> float:
if committed_spend is None:
return cached_spend + response_cost
return max(committed_spend + response_cost, cached_spend)
def _budgeted_org_counter(org_id: str | None, org: OrgBudgetSnapshot | None) -> EntitySpendCounterKeys:
if org is None or org.max_budget is None:
return EntitySpendCounterKeys()
return EntitySpendCounterKeys.of(token=None, team_id=None, user_id=None, org_id=org_id, end_user_id=None)
async def _committed_spend_now(counter_keys: EntitySpendCounterKeys, timeout: float) -> CommittedBudgetSpend:
if not counter_keys.tracked():
return CommittedBudgetSpend()
try:
from litellm.proxy.proxy_server import spend_counter_store
from litellm.proxy.spend_tracking.committed_spend import read_committed_budget_spend
return await asyncio.wait_for(
read_committed_budget_spend(spend_counter_store, counter_keys),
timeout=timeout,
)
except Exception as e: # noqa: BLE001 # a gauge read never fails the request; the gauges keep the stored spend
verbose_logger.debug("[Non-Blocking] Prometheus: committed spend read failed: %s", e)
return CommittedBudgetSpend()
OwnCharge: TypeAlias = Literal["unknown", "pending", "counted"]
def _own_charge() -> OwnCharge:
"""Whether this request's committed spend charge is in Redis yet, known only inside a request with Redis."""
try:
from litellm.proxy.proxy_server import spend_counter_store
except ImportError:
return "unknown"
batches: Final = active_request_redis_batches()
if batches is None or not spend_counter_store.has_redis:
return "unknown"
settlements: Final = batches.post_call_settlements
return "counted" if settlements and all(settlement.settled for settlement in settlements) else "pending"
def _committed_spend_before_request(
at_auth: CommittedBudgetSpend, now: CommittedBudgetSpend, response_cost: float, own_charge: OwnCharge
) -> CommittedBudgetSpend:
def before(at_auth_spend: float | None, now_spend: float | None) -> float | None:
if now_spend is None:
return at_auth_spend
match own_charge:
case "pending":
return now_spend
case "counted":
without_own_charge: Final = now_spend - response_cost
return without_own_charge if at_auth_spend is None else max(at_auth_spend, without_own_charge)
case "unknown":
if at_auth_spend is None:
return now_spend - response_cost
own_charge_counted: Final = now_spend - at_auth_spend >= response_cost * _OWN_CHARGE_MATCH_RATIO
return max(at_auth_spend, now_spend - response_cost) if own_charge_counted else now_spend
case _:
assert_never(own_charge)
return CommittedBudgetSpend(
key=before(at_auth.key, now.key),
team=before(at_auth.team, now.team),
user=before(at_auth.user, now.user),
org=before(at_auth.org, now.org),
end_user=before(at_auth.end_user, now.end_user),
)
async def _committed_spend_without_own_charge(
counter_keys: EntitySpendCounterKeys,
at_auth: CommittedBudgetSpend | None,
response_cost: float,
read_timeout: float,
) -> CommittedBudgetSpend:
own_charge_before_read: Final = _own_charge()
now: Final = await _committed_spend_now(counter_keys, read_timeout)
own_charge: Final = own_charge_before_read if _own_charge() == own_charge_before_read else "unknown"
return _committed_spend_before_request(
at_auth=now if at_auth is None else at_auth, now=now, response_cost=response_cost, own_charge=own_charge
)
def _get_proxy_llm_router() -> Router | None:
try:
from litellm.proxy.proxy_server import llm_router
@ -2229,6 +2335,10 @@ class PrometheusLogger(CustomLogger):
_user_max_budget: Final = _metadata.get("user_api_key_user_max_budget", None)
_user_email: Final = _metadata.get("user_api_key_user_email", None)
_org_alias: Final = _metadata.get("user_api_key_org_alias", None)
_carried_metadata: Final = _REQUEST_METADATA_ADAPTER.validate_python(_metadata)
_committed_at_auth: Final = committed_budget_spend_of_row(
_carried_metadata, _REQUEST_METADATA_ADAPTER.validate_python(litellm_params)
)
# Bound the per-request budget-metric emission so that slow Redis/DB
# lookups under load cannot consume the whole LoggingWorker watchdog
@ -2238,45 +2348,65 @@ class PrometheusLogger(CustomLogger):
# so dropping one slow per-request emission only loses sub-cron real-time
# detail, not correctness.
budget_metrics_timeout: Final = _get_budget_metrics_per_request_timeout()
gather_coro: Final = asyncio.gather(
self._set_api_key_budget_metrics_after_api_request(
user_api_key=user_api_key,
user_api_key_alias=user_api_key_alias,
committed_read_timeout: Final = budget_metrics_timeout / 2
async def emit_budget_metrics() -> tuple[object, ...]:
committed: Final = await _committed_spend_without_own_charge(
counter_keys=_committed_at_auth.counter_keys
if _committed_at_auth is not None
else EntitySpendCounterKeys.of(
token=user_api_key,
team_id=user_api_team,
user_id=user_id,
org_id=user_api_key_org_id,
end_user_id=end_user_id,
),
at_auth=_committed_at_auth,
response_cost=response_cost,
key_max_budget=_api_key_max_budget,
key_spend=_api_key_spend,
carried=KeyBudgetSnapshot.from_metadata(_metadata),
),
self._set_team_budget_metrics_after_api_request(
user_api_team=user_api_team,
user_api_team_alias=user_api_team_alias,
team_spend=_team_spend,
team_max_budget=_team_max_budget,
response_cost=response_cost,
carried=TeamBudgetSnapshot.from_metadata(_metadata),
),
self._set_user_budget_metrics_after_api_request(
user_id=user_id,
user_spend=_user_spend,
user_max_budget=_user_max_budget,
response_cost=response_cost,
carried=UserBudgetSnapshot.from_metadata(_metadata),
user_email=_user_email if isinstance(_user_email, str) else None,
),
self._set_org_budget_metrics_after_api_request(
org_id=user_api_key_org_id,
response_cost=response_cost,
carried=OrgBudgetSnapshot.from_metadata(_metadata),
org_alias=_org_alias if isinstance(_org_alias, str) else None,
),
self._set_customer_budget_metrics_after_api_request(
end_user_id=end_user_id,
response_cost=response_cost,
),
return_exceptions=True,
)
read_timeout=committed_read_timeout,
)
return await asyncio.gather(
self._set_api_key_budget_metrics_after_api_request(
user_api_key=user_api_key,
user_api_key_alias=user_api_key_alias,
response_cost=response_cost,
key_max_budget=_api_key_max_budget,
key_spend=_committed_floor(_stored_spend(_api_key_spend), committed.key),
carried=KeyBudgetSnapshot.from_metadata(_carried_metadata),
),
self._set_team_budget_metrics_after_api_request(
user_api_team=user_api_team,
user_api_team_alias=user_api_team_alias,
team_spend=_committed_floor(_stored_spend(_team_spend), committed.team),
team_max_budget=_team_max_budget,
response_cost=response_cost,
carried=TeamBudgetSnapshot.from_metadata(_carried_metadata),
),
self._set_user_budget_metrics_after_api_request(
user_id=user_id,
user_spend=_committed_floor(_stored_spend(_user_spend), committed.user),
user_max_budget=_user_max_budget,
response_cost=response_cost,
carried=UserBudgetSnapshot.from_metadata(_carried_metadata),
user_email=_user_email if isinstance(_user_email, str) else None,
),
self._set_org_budget_metrics_after_api_request(
org_id=user_api_key_org_id,
response_cost=response_cost,
carried=OrgBudgetSnapshot.from_metadata(_carried_metadata),
org_alias=_org_alias if isinstance(_org_alias, str) else None,
committed_spend=committed.org,
),
self._set_customer_budget_metrics_after_api_request(
end_user_id=end_user_id,
response_cost=response_cost,
committed_spend=committed.end_user,
),
return_exceptions=True,
)
try:
results: Final = await asyncio.wait_for(gather_coro, timeout=budget_metrics_timeout)
results: Final = await asyncio.wait_for(emit_budget_metrics(), timeout=budget_metrics_timeout)
except asyncio.TimeoutError:
verbose_logger.debug(
"[Non-Blocking] Prometheus: per-request budget metric emission "
@ -2745,7 +2875,7 @@ class PrometheusLogger(CustomLogger):
_labels,
)
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None:
verbose_logger.debug(
"prometheus Logging - Enters failure logging function (kwargs keys: %s)",
list(kwargs.keys()) if isinstance(kwargs, dict) else type(kwargs).__name__,
@ -2799,9 +2929,27 @@ class PrometheusLogger(CustomLogger):
label_context=PrometheusLabelFactoryContext(enum_values),
)
self.set_llm_deployment_failure_metrics(kwargs)
failure_metadata: Final = _REQUEST_METADATA_ADAPTER.validate_python(
get_litellm_metadata_from_kwargs(kwargs)
)
failure_org_alias: Final = failure_metadata.get("user_api_key_org_alias")
failure_org: Final = OrgBudgetSnapshot.from_metadata(failure_metadata)
failure_cost: Final = _response_cost_of(_LOGGED_FIELDS.validate_python(standard_logging_payload))
failure_committed: Final = await _committed_spend_without_own_charge(
counter_keys=_budgeted_org_counter(user_api_key_org_id, failure_org),
at_auth=committed_budget_spend_of_row(
failure_metadata, _REQUEST_METADATA_ADAPTER.validate_python(litellm_params)
)
or CommittedBudgetSpend(),
response_cost=failure_cost,
read_timeout=_get_budget_metrics_per_request_timeout(),
)
await self._set_org_budget_metrics_after_api_request(
org_id=user_api_key_org_id,
response_cost=0,
response_cost=failure_cost,
carried=failure_org,
org_alias=failure_org_alias if isinstance(failure_org_alias, str) else None,
committed_spend=failure_committed.org,
)
except Exception as e:
verbose_logger.exception("prometheus Layer Error(): Exception occured - %s", e)
@ -4516,6 +4664,7 @@ class PrometheusLogger(CustomLogger):
response_cost: float,
carried: OrgBudgetSnapshot | None = None,
org_alias: str | None = None,
committed_spend: float | None = None,
):
"""
Set org budget metrics after an LLM API request
@ -4533,7 +4682,7 @@ class PrometheusLogger(CustomLogger):
self._set_org_budget_metrics(
org_id=org_id,
org_alias=org_alias or "",
spend=carried.spend + response_cost,
spend=_committed_floor(carried.spend, committed_spend) + response_cost,
max_budget=carried.max_budget,
budget_reset_at=None,
)
@ -4559,7 +4708,7 @@ class PrometheusLogger(CustomLogger):
if org_info is None:
return
_total_org_spend: Final = (org_info.spend or 0.0) + response_cost
_total_org_spend: Final = _committed_floor(org_info.spend or 0.0, committed_spend) + response_cost
budget_table: Final = org_info.litellm_budget_table
max_budget: Final = budget_table.max_budget if budget_table else None
budget_reset_at: Final = getattr(budget_table, "budget_reset_at", None) if budget_table else None
@ -4626,6 +4775,7 @@ class PrometheusLogger(CustomLogger):
self,
end_user_id: str | None,
response_cost: float,
committed_spend: float | None = None,
):
if self._customer_budget_gauges_are_noop() or not _customer_budget_metrics_enabled():
return
@ -4651,7 +4801,9 @@ class PrometheusLogger(CustomLogger):
budget_table: Final = cached_customer.litellm_budget_table
self._set_customer_budget_metrics(
end_user_id=end_user_id,
spend=cached_customer.spend + response_cost,
spend=_customer_spend_after_request(
cached_spend=cached_customer.spend, committed_spend=committed_spend, response_cost=response_cost
),
max_budget=budget_table.max_budget if budget_table is not None else None,
budget_reset_at=None,
)

View file

@ -14,6 +14,7 @@ import secrets
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from enum import Enum
from functools import partial
from types import MappingProxyType
from typing import Final, NamedTuple, Protocol, Union, cast
@ -170,7 +171,15 @@ from litellm.proxy.db.db_lookup_gate import bounded_db_lookup
from litellm.proxy.db.db_span import db_span
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.spend_tracking.carried_budget_state import carry_team_and_user_budget_state
from litellm.proxy.spend_tracking.carried_budget_state import (
carry_committed_budget_spend,
carry_team_and_user_budget_state,
)
from litellm.proxy.spend_tracking.committed_spend import (
bind_committed_admission_counters,
committed_spend_tracked,
read_admission_committed_spend,
)
from litellm.proxy.spend_tracking.spend_counter_batch import (
bind_admission_counter_keys,
release_spend_counter_batch,
@ -3650,6 +3659,7 @@ async def run_centralized_common_checks(
model_max_budget_limiter,
prisma_client,
proxy_logging_obj,
spend_counter_store,
user_api_key_cache,
user_custom_auth,
)
@ -3960,6 +3970,7 @@ async def run_centralized_common_checks(
)
bind_admission_counter_keys(user_api_key_auth_obj, end_user_id=end_user_id)
bind_committed_admission_counters(user_api_key_auth_obj, end_user_id=end_user_id)
try:
_ = await common_checks(
request=request,
@ -4035,6 +4046,18 @@ async def run_centralized_common_checks(
user_obj=user_object,
)
if committed_spend_tracked():
await carry_committed_budget_spend(
valid_token=user_api_key_auth_obj,
team_object=typed_team_object,
user_object=user_object,
end_user_object=end_user_object,
read_committed=(
None
if route_skips_budget_checks(route=route)
else partial(read_admission_committed_spend, spend_counter_store)
),
)
await _reserve_budget_after_common_checks(
user_api_key_auth_obj=user_api_key_auth_obj,
request=request,
@ -4348,6 +4371,7 @@ async def _prefetch_referenced_auth_objects(
"""Warm every object and spend counter the checks below will read, in one MGET each (one DB query when cold).
Runs after the key's model access check so a denied request costs no more than it did before."""
bind_admission_counter_keys(valid_token, end_user_id=end_user_id or None)
bind_committed_admission_counters(valid_token, end_user_id=end_user_id or None)
await prefetch_auth_objects(
refs=AuthObjectRefs.from_token(valid_token),
user_api_key_cache=user_api_key_cache,

View file

@ -48,6 +48,7 @@ from litellm.proxy.db.budget_window_spend_writer import roll_window_spend_row
from litellm.proxy.db.db_span import db_span, db_spanned
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import POD_LOCK_TARGET, PodLockManager
from litellm.proxy.db.exception_handler import call_with_db_reconnect_retry
from litellm.proxy.spend_tracking.committed_spend import committed_spend_keys
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.redis.stores.spend_counters import (
SPEND_COUNTERS_TARGET,
@ -621,7 +622,7 @@ class ResetBudgetJob:
from litellm.proxy.proxy_server import spend_counter_store
try:
await spend_counter_store.delete((counter_key,))
await spend_counter_store.delete((counter_key, *committed_spend_keys((counter_key,))))
except Exception as e:
verbose_proxy_logger.warning(
"Failed to reset spend counter %s in Redis: %s. Budget may be over-enforced until counter expires.",
@ -680,7 +681,7 @@ class ResetBudgetJob:
match cache:
case "spend counter":
await spend_counter_store.delete(keys)
await spend_counter_store.delete((*keys, *committed_spend_keys(keys)))
case "user_api_key_cache":
await user_api_key_cache.async_delete_cache_keys(keys)
case _:

View file

@ -94,6 +94,10 @@ class SpendCounterReseed:
_locks: ClassVar["OrderedDict[str, asyncio.Lock]"] = OrderedDict()
_registry_lock: ClassVar[asyncio.Lock | None] = None
@staticmethod
async def counter_lock(counter_key: str) -> asyncio.Lock:
return await SpendCounterReseed._get_lock(counter_key)
@staticmethod
async def _get_lock(counter_key: str) -> asyncio.Lock:
if SpendCounterReseed._registry_lock is None:

View file

@ -47,6 +47,7 @@ from litellm.proxy.db.db_spend_update_writer import (
from litellm.proxy.hooks.search_free_tier import apply_search_free_tier
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, metadata_variable_name_for_request
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.spend_tracking.committed_spend import committed_spend_tracked, warm_committed_counter_keys
from litellm.proxy.spend_tracking.spend_counter_batch import post_call_counter_keys, spend_counter_batch_scope
from litellm.proxy.spend_tracking.spend_event import (
ObjectMapping,
@ -75,6 +76,8 @@ from litellm.proxy.utils import (
BilledFailedAttempt,
ProxyUpdateSpend,
)
from litellm.types.proxy.auth.user_api_key_auth import committed_budget_spend_of_row
from litellm.types.proxy.carried_budget_state import EntitySpendCounterKeys
from litellm.types.router import Deployment
from litellm.types.utils import (
CallTypes,
@ -268,6 +271,11 @@ def _proxy_spend_writer() -> DBSpendUpdateWriter:
return proxy_logging_obj.db_spend_update_writer
def _committed_spend_scope(user_api_key_dict: UserAPIKeyAuth) -> EntitySpendCounterKeys:
committed: Final = user_api_key_dict.committed_budget_spend
return committed.counter_keys if committed is not None else EntitySpendCounterKeys()
def _proxy_spend_counters() -> "_IncrementSpendCounters":
from litellm.proxy.proxy_server import increment_spend_counters
@ -639,6 +647,7 @@ class _ProxyDBLogger(CustomLogger):
team_id=user_api_key_dict.team_id,
org_id=user_api_key_dict.org_id,
project_id=user_api_key_dict.project_id,
committed_spend_scope=_committed_spend_scope(user_api_key_dict),
kwargs=dict(row_kwargs),
completion_response=original_exception,
start_time=start_time,
@ -757,6 +766,7 @@ class _ProxyDBLogger(CustomLogger):
router=get_llm_router(),
),
project_id=user_api_key_dict.project_id,
committed_spend_scope=_committed_spend_scope(user_api_key_dict),
**_ledger_counter_options(row_kwargs.get(ACCOUNTING_LEDGER_KWARG)),
)
except Exception: # noqa: BLE001 # one row's counter failure must not keep the other rows' counters from landing
@ -1269,10 +1279,38 @@ class _IncrementSpendCounters(Protocol):
request_started_at: datetime | None = None,
model_access_groups: Sequence[str] | None = None,
project_id: str | None = None,
committed_spend_scope: EntitySpendCounterKeys | None = None,
windows_from_ledger: bool = False,
) -> None: ...
async def _committed_spend_scope_of_row(
kwargs: Mapping[str, object],
response_cost: float,
token: str | None,
team_id: str | None,
user_id: str | None,
org_id: str | None,
end_user_id: str | None,
) -> EntitySpendCounterKeys:
metadata: Final = _OBJECT_MAPPING.validate_python(get_litellm_metadata_from_kwargs(kwargs=dict(kwargs)))
carried: Final = committed_budget_spend_of_row(
metadata, _OBJECT_MAPPING.validate_python(kwargs.get("litellm_params") or {})
)
if carried is not None:
return carried.counter_keys
if response_cost <= 0 or not committed_spend_tracked():
return EntitySpendCounterKeys()
from litellm.proxy.proxy_server import spend_counter_store
return await warm_committed_counter_keys(
spend_counter_store,
EntitySpendCounterKeys.of(
token=token, team_id=team_id, user_id=user_id, org_id=org_id, end_user_id=end_user_id
),
)
async def _update_database_and_spend_counters(
proxy_logging_obj: "ProxyLogging",
increment_spend_counters: _IncrementSpendCounters,
@ -1291,6 +1329,7 @@ async def _update_database_and_spend_counters(
model_access_groups: Sequence[str] | None = None,
project_id: str | None = None,
spend_writer: DBSpendUpdateWriter | None = None,
committed_spend_scope: EntitySpendCounterKeys | None = None,
) -> bool:
"""The reservation is reconciled before the spend is persisted, from its own read. One spend counter batch then
spans the database write and the counter update, so the post-call counters are read with a single MGET after the
@ -1333,6 +1372,7 @@ async def _update_database_and_spend_counters(
request_tags=request_tags,
model_access_groups=model_access_groups,
project_id=project_id,
committed_spend_scope=committed_spend_scope,
)
@ -1354,6 +1394,7 @@ async def _update_database_and_spend_counters_in_batch(
model_access_groups: Sequence[str] | None,
project_id: str | None,
spend_writer: DBSpendUpdateWriter | None = None,
committed_spend_scope: EntitySpendCounterKeys | None = None,
) -> bool:
try:
writer: Final = spend_writer if spend_writer is not None else proxy_logging_obj.db_spend_update_writer
@ -1407,6 +1448,19 @@ async def _update_database_and_spend_counters_in_batch(
request_started_at=start_time,
model_access_groups=model_access_groups,
project_id=project_id,
committed_spend_scope=(
committed_spend_scope
if committed_spend_scope is not None
else await _committed_spend_scope_of_row(
kwargs=kwargs,
response_cost=response_cost,
token=user_api_key,
team_id=team_id,
user_id=user_id,
org_id=org_id,
end_user_id=end_user_id,
)
),
**_ledger_counter_options(_BATCH_CALLBACK_FIELDS.validate_python(kwargs).get(ACCOUNTING_LEDGER_KWARG)),
)
except Exception:

View file

@ -2156,7 +2156,14 @@ class LiteLLMProxyRequestSetup:
user_api_key_dict.matched_model_access_groups
)
# UserAPIKeyAuth object for MCP server access control
data[_metadata_variable_name]["user_api_key_auth"] = user_api_key_dict.model_copy(
data[_metadata_variable_name]["user_api_key_auth"] = LiteLLMProxyRequestSetup.auth_for_request_metadata(
user_api_key_dict
)
return data
@staticmethod
def auth_for_request_metadata(user_api_key_dict: UserAPIKeyAuth) -> UserAPIKeyAuth:
return user_api_key_dict.model_copy(
update={
"metadata": strip_callback_config(user_api_key_dict.metadata),
"team_metadata": strip_callback_config(user_api_key_dict.team_metadata),
@ -2164,7 +2171,6 @@ class LiteLLMProxyRequestSetup:
"organization_metadata": strip_callback_config(user_api_key_dict.organization_metadata),
}
)
return data
@staticmethod
def add_management_endpoint_metadata_to_request_metadata(

View file

@ -42,7 +42,9 @@ from litellm.proxy.management_helpers.object_permission_utils import ( # noqa:
handle_update_object_permission_common,
set_object_permission,
)
from litellm.proxy.spend_tracking.committed_spend import drop_committed_spend
from litellm.proxy.utils import handle_exception_on_proxy
from litellm.redis.stores.spend_counters import end_user_counter
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.table_repositories import EndUserRepository
@ -812,7 +814,7 @@ async def delete_end_user(
See below for all params
```
"""
from litellm.proxy.proxy_server import prisma_client
from litellm.proxy.proxy_server import prisma_client, spend_counter_store
try:
if prisma_client is None:
@ -842,6 +844,9 @@ async def delete_end_user(
verbose_proxy_logger.debug("received response from updating prisma client. response=%s", response)
await _evict_end_user_cache_keys(_end_user_cache_keys(data.user_ids))
await drop_committed_spend(
spend_counter_store, tuple(end_user_counter(user_id) for user_id in data.user_ids)
)
return DeleteCustomersResponse(
deleted_customers=response,

View file

@ -1483,8 +1483,11 @@ async def _invalidate_user_spend_counter_if_changed(
"""
if non_default_values.get("spend") is not None:
from litellm.proxy.proxy_server import invalidate_spend_counter
from litellm.proxy.spend_tracking.committed_spend import committed_spend_key
await invalidate_spend_counter(counter_key=user_counter(str(non_default_values["user_id"])))
counter_key: Final = user_counter(str(non_default_values["user_id"]))
await invalidate_spend_counter(counter_key=counter_key)
await invalidate_spend_counter(counter_key=committed_spend_key(counter_key))
def _clears_object_permission(user_request: UpdateUserRequest) -> bool:

View file

@ -158,6 +158,7 @@ from litellm.proxy.mcp_server.outbound_credentials.sso_assertion_store import (
)
from litellm.proxy.search_endpoints.search_tool_registry import rotate_search_tools_master_key
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.spend_tracking.committed_spend import committed_spend_key
from litellm.proxy.spend_tracking.spend_tracking_utils import ( # noqa: F401 # legacy module exports
_is_master_key, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
is_master_key,
@ -4288,7 +4289,7 @@ async def update_key_fn(
)
if data.spend is not None:
from litellm.proxy.proxy_server import spend_counter_store
from litellm.proxy.proxy_server import invalidate_spend_counter, spend_counter_store
counter_key: Final = key_counter(hash_token_if_needed(key))
try:
@ -4300,6 +4301,7 @@ async def update_key_fn(
counter_key,
redis_err,
)
await invalidate_spend_counter(counter_key=committed_spend_key(counter_key))
asyncio.create_task(
KeyManagementEventHooks.async_key_updated_hook(
@ -6967,6 +6969,7 @@ async def reset_key_spend_fn(
try:
from litellm.proxy.proxy_server import (
hash_token,
invalidate_spend_counter,
prisma_client,
proxy_logging_obj,
set_spend_counter_with_floor_and_broadcast,
@ -7022,6 +7025,7 @@ async def reset_key_spend_fn(
# pre-reset value.
_counter_key: Final = key_counter(hashed_api_key)
await set_spend_counter_with_floor_and_broadcast(counter_key=_counter_key, value=reset_to)
await invalidate_spend_counter(counter_key=committed_spend_key(_counter_key))
await _reset_key_budget_windows(
prisma_client=prisma_client,
hashed_api_key=hashed_api_key,

View file

@ -66,7 +66,9 @@ from litellm.proxy.management_helpers.utils import (
get_new_internal_user_defaults,
management_endpoint_wrapper,
)
from litellm.proxy.spend_tracking.committed_spend import drop_committed_spend
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.redis.stores.spend_counters import org_counter, team_counter
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
@ -93,6 +95,7 @@ if TYPE_CHECKING:
LiteLLM_OrganizationMembership as PrismaOrganizationMembership,
)
from prisma.models import LiteLLM_OrganizationTable as PrismaOrganizationTable
from prisma.models import LiteLLM_TeamTable as PrismaTeamTable
from prisma.models import LiteLLM_UserTable as PrismaUserTable
from prisma.models import LiteLLM_VerificationToken as PrismaVerificationToken
@ -180,6 +183,8 @@ class _OrganizationMembershipTableClient(Protocol):
class _TeamTableClient(Protocol):
async def find_many(self, *, where: Mapping[str, object]) -> "Sequence[PrismaTeamTable]": ...
async def delete_many(self, where: Mapping[str, object]) -> int: ...
@ -819,6 +824,11 @@ async def update_organization(
include={"members": True, "teams": True, "litellm_budget_table": True},
)
if data.spend is not None:
from litellm.proxy.proxy_server import spend_counter_store
await drop_committed_spend(spend_counter_store, (org_counter(data.organization_id),))
return response
@ -1045,7 +1055,8 @@ async def delete_organization(
teams_in_deleted_orgs: Final = await find_many_in(
_table(TeamRepository(prisma_client)), "organization_id", data.organization_ids
)
raise_if_teams_hold_ptu_shares(tuple(team.team_id for team in teams_in_deleted_orgs), llm_router)
team_ids_in_deleted_orgs: Final = tuple(team.team_id for team in teams_in_deleted_orgs)
raise_if_teams_hold_ptu_shares(team_ids_in_deleted_orgs, llm_router)
deleted_orgs: Final = []
for organization_id in data.organization_ids:
@ -1073,6 +1084,15 @@ async def delete_organization(
)
deleted_orgs.append(deleted_org)
from litellm.proxy.proxy_server import spend_counter_store
await drop_committed_spend(
spend_counter_store,
(
*(org_counter(organization_id) for organization_id in data.organization_ids),
*(team_counter(team_id) for team_id in team_ids_in_deleted_orgs),
),
)
return deleted_orgs

View file

@ -226,12 +226,14 @@ from litellm.proxy.management_helpers.utils import (
add_new_member,
management_endpoint_wrapper,
)
from litellm.proxy.spend_tracking.committed_spend import drop_committed_spend
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
handle_exception_on_proxy,
query_raw_within_statement_timeout,
)
from litellm.redis.stores.spend_counters import team_counter
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import delete_many_in, find_many_in
from litellm.repositories.daily_activity_buckets import api_key_from_bucket
@ -4863,6 +4865,7 @@ async def delete_validated_team_rows(
"""
from litellm.proxy.proxy_server import (
proxy_logging_obj,
spend_counter_store,
user_api_key_cache,
)
@ -4934,7 +4937,9 @@ async def delete_validated_team_rows(
_emit_team_members_metric(deleted_team.model_copy(update={"members_with_roles": ()}))
await sync_team_access_group_membership(prisma_client=prisma_client, team_id=deleted_team.team_id)
return DeletedTeams(team_ids=tuple(team.team_id for team in locked))
deleted_team_ids: Final = tuple(team.team_id for team in locked)
await drop_committed_spend(spend_counter_store, tuple(team_counter(team_id) for team_id in deleted_team_ids))
return DeletedTeams(team_ids=deleted_team_ids)
def _locked_roster_user_ids(locked: Sequence[LiteLLM_TeamTable]) -> tuple[str, ...]:

View file

@ -46,7 +46,9 @@ from litellm.proxy.management_helpers.access_group_team_sync import (
TEAM_ADVISORY_LOCK_SQL,
USER_ROWS_LOCK_SQL,
)
from litellm.proxy.spend_tracking.committed_spend import drop_committed_spend
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.redis.stores.spend_counters import user_counter
from litellm.repositories.table_repositories import (
OrganizationMembershipRepository,
TeamMembershipRepository,
@ -585,6 +587,9 @@ async def _after_users_deleted(
)
await evict_and_broadcast(cache_keys=deletion.jwt_mapping_cache_keys, user_api_key_cache=user_api_key_cache)
await evict_and_broadcast(cache_keys=sorted(user_ids), user_api_key_cache=user_api_key_cache)
from litellm.proxy.proxy_server import spend_counter_store
await drop_committed_spend(spend_counter_store, tuple(user_counter(user_id) for user_id in sorted(user_ids)))
for removal in deletion.removals.values():
_emit_team_members_metric(removal.team)

View file

@ -165,6 +165,8 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
WebSocketSessionUsage,
WebsocketUpstreamFrameKind,
)
from litellm.types.proxy.auth.user_api_key_auth import COMMITTED_BUDGET_SPEND_PARAM
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD, CallTypes, Usage
from .llm_provider_handlers.tinyfish_passthrough_logging_handler import (
@ -779,6 +781,7 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
"litellm_params": {
**litellm_params_in_body,
"metadata": _metadata,
COMMITTED_BUDGET_SPEND_PARAM: user_api_key_dict.committed_budget_spend or CommittedBudgetSpend(),
"proxy_server_request": {
"url": str(request.url),
"method": request.method,

View file

@ -93,6 +93,7 @@ from litellm.constants import (
LITELLM_UI_SESSION_DURATION,
OPENAI_LIVE_SESSION_START_TIMEOUT_SECONDS,
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS,
VLLM_CHOICE_FIELDS,
)
from litellm.litellm_core_utils.access_windows import access_windows_config_error
@ -338,7 +339,7 @@ from litellm._logging import (
verbose_router_logger,
)
from litellm.caching.caching import DualCache, RedisCache
from litellm.caching.redis_batch import drain_post_call_redis_batches
from litellm.caching.redis_batch import active_request_redis_batches, drain_post_call_redis_batches
from litellm.caching.redis_cache import RedisCircuitBreakerOpenError, is_redis_timeout_failure
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.constants import (
@ -995,12 +996,20 @@ from litellm.proxy.spend_tracking.budget_reservation import (
release_unbound_budget_reservation,
stamp_budget_reservation_actual_cost,
)
from litellm.proxy.spend_tracking.committed_spend import (
committed_spend_key,
committed_spend_keys,
has_committed_twin,
is_committed_spend_key,
seed_committed_spend,
)
from litellm.proxy.spend_tracking.spend_capture_rate import (
run_scheduled_spend_capture_rate_check,
)
from litellm.proxy.spend_tracking.spend_counter_batch import (
PendingSpendIncrement,
active_spend_counter_batch,
bind_spend_counter_keys,
forget_spend_counter,
post_call_counter_keys,
read_batched_spend_counter,
@ -1105,6 +1114,7 @@ from litellm.types.llms.openai import (
ResponseInProgressEvent,
ResponsesAPIResponse,
)
from litellm.types.proxy.carried_budget_state import EntitySpendCounterKeys
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupInfoProxy,
@ -3240,6 +3250,7 @@ proxy_budget_rescheduler_min_time = PROXY_BUDGET_RESCHEDULER_MIN_TIME
proxy_budget_rescheduler_max_time = PROXY_BUDGET_RESCHEDULER_MAX_TIME
proxy_batch_polling_interval = PROXY_BATCH_POLLING_INTERVAL
proxy_batch_write_at = PROXY_BATCH_WRITE_AT
_BATCH_WRITE_JITTER_MAX_SECONDS: Final = 5
proxy_config_reload_interval_seconds = PROXY_CONFIG_RELOAD_INTERVAL_SECONDS
litellm_master_key_hash = None
disable_spend_logs = False
@ -3638,6 +3649,7 @@ async def increment_spend_counters(
request_started_at: datetime | None = None,
model_access_groups: Sequence[str] | None = None,
project_id: str | None = None,
committed_spend_scope: EntitySpendCounterKeys | None = None,
windows_from_ledger: bool = False,
):
"""
@ -3675,6 +3687,7 @@ async def increment_spend_counters(
request_started_at=request_started_at,
model_access_groups=model_access_groups,
project_id=project_id,
committed_spend_scope=committed_spend_scope,
windows_from_ledger=windows_from_ledger,
)
@ -3790,10 +3803,17 @@ async def _increment_spend_counters_batched(
request_started_at: datetime | None,
model_access_groups: Sequence[str] | None,
project_id: str | None = None,
committed_spend_scope: EntitySpendCounterKeys | None = None,
windows_from_ledger: bool = False,
):
"""Runs inside one spend counter batch: the reservation reconcile and the warm checks share a single MGET, and
the reconcile adjustments go out in the same INCRBYFLOAT pipeline as the counter increments."""
committed_counter_keys: Final = (
tuple(counter_key for counter_key in committed_spend_scope.tracked() if has_committed_twin(counter_key))
if committed_spend_scope is not None and response_cost is not None and response_cost > 0
else ()
)
bind_spend_counter_keys(frozenset(committed_spend_keys(committed_counter_keys)))
reservation_update: Final = await _reconcile_budget_reservation_for_counter_update(
budget_reservation=budget_reservation,
response_cost=response_cost,
@ -3980,6 +4000,9 @@ async def _increment_spend_counters_batched(
)
if project_id is not None
else None,
_prepare_committed_spend_increments(counter_keys=committed_counter_keys, increment=cost)
if committed_counter_keys
else None,
)
if coro is not None
)
@ -4082,6 +4105,23 @@ async def _reconcile_budget_reservation_for_counter_update(
return _ReservationCounterUpdate(reserved_counter_keys=frozenset(reserved_counter_keys), pending=pending)
async def _committed_spend_seeded(counter_key: str) -> bool:
return await _is_spend_counter_cache_warm(committed_spend_key(counter_key)) or await seed_committed_spend(
spend_counter_store=spend_counter_store, prisma_client=prisma_client, counter_key=counter_key
)
async def _prepare_committed_spend_increments(
counter_keys: tuple[str, ...], increment: float
) -> tuple[PendingSpendIncrement, ...]:
seeded: Final = await asyncio.gather(*(_committed_spend_seeded(counter_key) for counter_key in counter_keys))
return tuple(
PendingSpendIncrement(counter_key=committed_spend_key(counter_key), increment=increment)
for counter_key, is_seeded in zip(counter_keys, seeded)
if is_seeded
)
async def _prepare_end_user_and_tag_spend_increments(
end_user_id: str | None,
tags: list[str] | None,
@ -4487,7 +4527,15 @@ def _defer_spend_counter_increments(pending: Sequence[PendingSpendIncrement]) ->
"""Post-call increments ride the request's native post-call pipeline with the other counters. Each counter's
new value lands in memory when the pipeline settles; a failed one is dropped everywhere so no reader trusts a
counter whose increment may not have applied, as ``increment_spend_counters_pipeline`` does."""
return spend_counter_store.increment_many_post_call(tuple((item.counter_key, item.increment) for item in pending))
settlement: Final = spend_counter_store.increment_many_post_call(
tuple((item.counter_key, item.increment) for item in pending)
)
if settlement is None:
return False
batches: Final = active_request_redis_batches()
if batches is not None and any(is_committed_spend_key(item.counter_key) for item in pending):
batches.track_post_call_settlement(settlement)
return True
async def increment_spend_counters_pipeline(pending: Sequence[PendingSpendIncrement]) -> tuple[float | None, ...]:
@ -11651,7 +11699,10 @@ class ProxyStartupEvent:
)
# Ensure minimum interval of 30 seconds for batch writing to prevent memory issues
batch_writing_interval: Final = proxy_batch_write_at + random.randint(0, 5)
batch_writing_interval: Final = proxy_batch_write_at + random.randint(0, _BATCH_WRITE_JITTER_MAX_SECONDS)
spend_counter_store.set_committed_ttl(
2 * (SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS + proxy_batch_write_at + _BATCH_WRITE_JITTER_MAX_SECONDS)
)
### PROXY WORKER HEARTBEAT ###
worker_heartbeat: Final = ProxyWorkerHeartbeat(prisma_client=prisma_client)

View file

@ -1,14 +1,17 @@
"""Pins the budget state auth resolved onto ``UserAPIKeyAuth`` and emits it as request metadata."""
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping
from types import MappingProxyType
from typing import Final
from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.models.organization import LiteLLM_OrganizationTable
from litellm.models.team import LiteLLM_TeamTable
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.proxy.carried_budget_state import (
CommittedBudgetSpend,
EntitySpendCounterKeys,
OrgBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
@ -34,6 +37,41 @@ def carry_team_and_user_budget_state(
)
def _budgeted_org_id(valid_token: UserAPIKeyAuth, team_object: LiteLLM_TeamTable | None) -> str | None:
org_snapshot: Final = valid_token.org_budget_snapshot
if org_snapshot is None or org_snapshot.max_budget is None:
return None
return valid_token.org_id or (team_object.organization_id if team_object is not None else None)
def _budgeted_end_user_id(end_user_object: LiteLLM_EndUserTable | None) -> str | None:
budget_table: Final = end_user_object.litellm_budget_table if end_user_object is not None else None
if end_user_object is None or budget_table is None or budget_table.max_budget is None:
return None
return end_user_object.user_id
async def carry_committed_budget_spend(
valid_token: UserAPIKeyAuth,
team_object: LiteLLM_TeamTable | None,
user_object: LiteLLM_UserTable | None,
end_user_object: LiteLLM_EndUserTable | None,
read_committed: Callable[[EntitySpendCounterKeys], Awaitable[CommittedBudgetSpend]] | None,
) -> None:
counter_keys: Final = EntitySpendCounterKeys.of(
token=valid_token.token if valid_token.max_budget is not None else None,
team_id=team_object.team_id if team_object is not None and team_object.max_budget is not None else None,
user_id=user_object.user_id if user_object is not None and user_object.max_budget is not None else None,
org_id=_budgeted_org_id(valid_token=valid_token, team_object=team_object),
end_user_id=_budgeted_end_user_id(end_user_object=end_user_object),
)
valid_token.committed_budget_spend = ( # rebind-ok: request-scoped credential
CommittedBudgetSpend(counter_keys=counter_keys)
if read_committed is None
else await read_committed(counter_keys)
)
def carry_organization_budget_state(valid_token: UserAPIKeyAuth, org_table: LiteLLM_OrganizationTable) -> None:
budget_table: Final = org_table.litellm_budget_table
valid_token.organization_alias = org_table.organization_alias

View file

@ -0,0 +1,181 @@
"""Committed spend counters: twins of the entity spend counters that only ever take charged cost, never budget holds.
The remaining budget gauges read them, so a request's worst-case reservation never shows as spent. They are kept
only for entities with a budget, while a ``PrometheusLogger`` is registered, in the same ``SpendCounterStore`` as the
counters they twin.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterable, Mapping, Sequence
from typing import TYPE_CHECKING, Final
import litellm
from litellm._internal_context import with_service_target
from litellm._logging import verbose_proxy_logger
from litellm.caching.redis_batch import Batch
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
from litellm.proxy.spend_tracking.spend_counter_batch import (
admission_counter_keys,
bind_spend_counter_keys,
read_batched_spend_counter,
)
from litellm.redis.stores.spend_counters import (
COMMITTED_COUNTER_PREFIX,
END_USER_COUNTER_PREFIX,
SPEND_COUNTERS_TARGET,
SpendCounterStore,
committed_counter,
)
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
_BASE_COUNTER_PREFIXES: Final = ("spend:key:", "spend:team:", "spend:user:", "spend:org:", END_USER_COUNTER_PREFIX)
def committed_spend_key(counter_key: str) -> str:
return committed_counter(counter_key)
def has_committed_twin(counter_key: str) -> bool:
return counter_key.startswith(_BASE_COUNTER_PREFIXES) and ":window:" not in counter_key
def committed_spend_keys(counter_keys: Iterable[str]) -> tuple[str, ...]:
return tuple(committed_spend_key(counter_key) for counter_key in counter_keys if has_committed_twin(counter_key))
def is_committed_spend_key(counter_key: str) -> bool:
return counter_key.startswith(COMMITTED_COUNTER_PREFIX)
def committed_spend_tracked() -> bool:
return litellm.logging_callback_manager.callback_is_active(callback_type=PrometheusLogger)
def bind_committed_admission_counters(token: UserAPIKeyAuth, end_user_id: str | None) -> None:
if committed_spend_tracked():
bind_spend_counter_keys(frozenset(committed_spend_keys(admission_counter_keys(token, end_user_id))))
async def _read_committed_values(
spend_counter_store: SpendCounterStore, twin_keys: tuple[str, ...]
) -> Mapping[str, float]:
batched: Final = await spend_counter_store.read_many(twin_keys, batch=Batch.NONE)
if batched is not None:
return {twin_key: value for twin_key, value in batched.items() if value is not None}
reads: Final = await asyncio.gather(*(spend_counter_store.read(twin_key) for twin_key in twin_keys))
return {twin_key: value for twin_key, (value, _) in zip(twin_keys, reads, strict=True) if value is not None}
def _committed_budget_spend(
counter_keys: EntitySpendCounterKeys, committed: Mapping[str, float]
) -> CommittedBudgetSpend:
def spend_of(counter_key: str | None) -> float | None:
return committed.get(committed_spend_key(counter_key)) if counter_key is not None else None
return CommittedBudgetSpend(
counter_keys=counter_keys,
key=spend_of(counter_keys.key),
team=spend_of(counter_keys.team),
user=spend_of(counter_keys.user),
org=spend_of(counter_keys.org),
end_user=spend_of(counter_keys.end_user),
)
@with_service_target(SPEND_COUNTERS_TARGET)
async def read_committed_budget_spend(
spend_counter_store: SpendCounterStore, counter_keys: EntitySpendCounterKeys
) -> CommittedBudgetSpend:
twin_keys: Final = committed_spend_keys(counter_keys.tracked())
if not twin_keys:
return CommittedBudgetSpend(counter_keys=counter_keys)
try:
committed: Final = await _read_committed_values(spend_counter_store, twin_keys)
except Exception as e: # noqa: BLE001 # a gauge read never fails the request; the gauges keep the stored spend
verbose_proxy_logger.debug("committed spend read failed for %s: %s", twin_keys, e)
return CommittedBudgetSpend(counter_keys=counter_keys)
return _committed_budget_spend(counter_keys=counter_keys, committed=committed)
async def warm_committed_counter_keys(
spend_counter_store: SpendCounterStore, counter_keys: EntitySpendCounterKeys
) -> EntitySpendCounterKeys:
"""For a charge with no auth to name the budgeted entities: only a budgeted entity's twin exists."""
committed: Final = await read_committed_budget_spend(spend_counter_store, counter_keys)
return EntitySpendCounterKeys(
key=counter_keys.key if committed.key is not None else None,
team=counter_keys.team if committed.team is not None else None,
user=counter_keys.user if committed.user is not None else None,
org=counter_keys.org if committed.org is not None else None,
end_user=counter_keys.end_user if committed.end_user is not None else None,
)
async def read_admission_committed_spend(
spend_counter_store: SpendCounterStore, counter_keys: EntitySpendCounterKeys
) -> CommittedBudgetSpend:
"""Rides the admission MGET: a counter the batch cannot answer stays unknown here and the emit-time read has it."""
if not spend_counter_store.has_redis:
return await read_committed_budget_spend(spend_counter_store, counter_keys)
twin_keys: Final = committed_spend_keys(counter_keys.tracked())
batched: Final = await asyncio.gather(*(read_batched_spend_counter(twin_key) for twin_key in twin_keys))
return _committed_budget_spend(
counter_keys=counter_keys,
committed={
twin_key: read[0] for twin_key, read in zip(twin_keys, batched) if read is not None and read[0] is not None
},
)
async def drop_committed_spend(spend_counter_store: SpendCounterStore, counter_keys: Sequence[str]) -> None:
"""After a direct write to the stored spend the twin holds the old total, so the next charge seeds it again."""
twin_keys: Final = committed_spend_keys(counter_keys)
if not twin_keys:
return
try:
await spend_counter_store.delete(twin_keys)
except Exception as e: # noqa: BLE001 # the write already committed; a kept twin lasts until charges stop for its TTL
verbose_proxy_logger.warning("committed spend drop failed: %s", e)
@with_service_target(SPEND_COUNTERS_TARGET)
async def seed_committed_spend(
spend_counter_store: SpendCounterStore, prisma_client: PrismaClient | None, counter_key: str
) -> bool:
"""One DB read per worker for a cold twin: charges waiting on the lock find the twin the first one wrote."""
twin_key: Final = committed_spend_key(counter_key)
async with await SpendCounterReseed.counter_lock(twin_key):
try:
stored, _ = await spend_counter_store.read(twin_key)
except Exception as e: # noqa: BLE001 # an unseeded twin only skips this charge; the next one seeds it again
verbose_proxy_logger.debug("committed spend seed read failed for %s: %s", twin_key, e)
return False
return stored is not None or await _seed_committed_spend_from_db(
spend_counter_store, prisma_client, counter_key
)
async def _seed_committed_spend_from_db(
spend_counter_store: SpendCounterStore, prisma_client: PrismaClient | None, counter_key: str
) -> bool:
db_spend: Final = await (
SpendCounterReseed.end_user_from_db(prisma_client=prisma_client, counter_key=counter_key)
if counter_key.startswith(END_USER_COUNTER_PREFIX)
else SpendCounterReseed.from_db(prisma_client=prisma_client, counter_key=counter_key)
)
if db_spend is None:
return False
twin_key: Final = committed_spend_key(counter_key)
try:
await spend_counter_store.seed(twin_key, db_spend, increment_if_lost=False)
except Exception as e: # noqa: BLE001 # an unseeded twin only skips this charge; the next one seeds it again
verbose_proxy_logger.debug("committed spend seed failed for %s: %s", twin_key, e)
return False
return True

View file

@ -2,7 +2,7 @@
import collections
import json
import os
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from functools import partial
@ -53,6 +53,7 @@ from litellm.proxy.auth.authorization_dependencies import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.openai_error_payload import error_status_code, openai_error_param, openai_error_type
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.spend_tracking.committed_spend import committed_spend_tracked, drop_committed_spend
from litellm.proxy.spend_tracking.session_page import (
AGENT_CALL_TYPE_SQL,
MCP_CALL_TYPES_SQL,
@ -74,6 +75,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import (
get_spend_by_team_and_customer,
)
from litellm.proxy.utils import handle_exception_on_proxy, query_raw_within_statement_timeout
from litellm.redis.stores.spend_counters import key_counter, team_counter
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import SpendLogsRepository
from litellm.repositories.team_repository import TeamRepository
@ -3595,6 +3597,50 @@ async def view_spend_logs(
)
class _EntityIdRow(TypedDict):
id: ReadOnly[str]
_COMMITTED_DROP_PAGE_SIZE: Final = 1000
_CommittedDropEntity: TypeAlias = Literal["key", "team"]
_COMMITTED_DROP_PAGE_QUERIES: Final[Mapping[_CommittedDropEntity, str]] = MappingProxyType(
{
"key": 'SELECT token AS id FROM "LiteLLM_VerificationToken" WHERE token > $1 ORDER BY token LIMIT $2',
"team": 'SELECT team_id AS id FROM "LiteLLM_TeamTable" WHERE team_id > $1 ORDER BY team_id LIMIT $2',
}
)
_COMMITTED_DROP_COUNTERS: Final[Mapping[_CommittedDropEntity, Callable[[str], str]]] = MappingProxyType(
{"key": key_counter, "team": team_counter}
)
async def _drop_committed_spend_page(
prisma_client: PrismaClient, entity: _CommittedDropEntity, after_id: str
) -> str | None:
from litellm.proxy.proxy_server import spend_counter_store
rows: Final[Sequence[_EntityIdRow]] = await _query_raw(
prisma_client, _COMMITTED_DROP_PAGE_QUERIES[entity], after_id, _COMMITTED_DROP_PAGE_SIZE
)
counter_of: Final = _COMMITTED_DROP_COUNTERS[entity]
await drop_committed_spend(spend_counter_store, tuple(counter_of(row["id"]) for row in rows))
return rows[-1]["id"] if len(rows) == _COMMITTED_DROP_PAGE_SIZE else None
async def _drop_committed_spend_pages(prisma_client: PrismaClient, entity: _CommittedDropEntity) -> None:
after_id: str | None = "" # rebind-ok: the keyset cursor moves one page at a time
while after_id is not None:
after_id = await _drop_committed_spend_page(prisma_client, entity, after_id)
async def _drop_all_key_and_team_committed_spend(prisma_client: PrismaClient) -> None:
try:
await _drop_committed_spend_pages(prisma_client, "key")
await _drop_committed_spend_pages(prisma_client, "team")
except Exception as e: # noqa: BLE001 # the reset already committed; a kept twin lasts until charges stop for its TTL
verbose_proxy_logger.warning("committed spend drop after a global reset failed: %s", e)
@router.post(
"/global/spend/reset",
tags=["Budget & Spend Tracking"],
@ -3626,6 +3672,8 @@ async def global_spend_reset():
_ = await transaction.litellm_teamtable.update_many(data={"spend": 0.0}, where={})
_ = await transaction.query_raw(_LOCK_ALL_KEYS_SQL)
_ = await transaction.litellm_verificationtoken.update_many(data={"spend": 0.0}, where={})
if committed_spend_tracked():
await _drop_all_key_and_team_committed_spend(prisma_client)
return {
"message": "Spend for all API Keys and Teams reset successfully",

View file

@ -9263,13 +9263,14 @@ async def monitor_spend_logs_queue(
proxy_logging_obj: Proxy logging object
"""
from litellm.constants import (
SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS,
SPEND_LOG_QUEUE_POLL_INTERVAL,
SPEND_LOG_QUEUE_SIZE_THRESHOLD,
)
threshold: Final = SPEND_LOG_QUEUE_SIZE_THRESHOLD
base_interval: Final = SPEND_LOG_QUEUE_POLL_INTERVAL
max_backoff: Final = 30.0 # Maximum backoff interval in seconds
max_backoff: Final = SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS
backoff_multiplier: Final = 1.5 # Exponential backoff multiplier
current_interval = base_interval
flush_requested: Final = asyncio.Event()

View file

@ -6,15 +6,17 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from litellm._internal_context import with_service_target
from litellm.caching.redis_batch import Batch
from litellm.caching.redis_batch import Batch, PostCallSettlement
from litellm.redis.stores._route import post_call_request, ready, route
from litellm.redis.stores.spend_counters.handle import SpendCountersHandle
from litellm.redis.stores.spend_counters.types import (
COMMITTED_COUNTER_PREFIX,
END_USER_COUNTER_PREFIX,
SPEND_COUNTER_PREFIX,
SPEND_COUNTERS_TARGET,
SpendIncrement,
SpendReading,
committed_counter,
end_user_counter,
is_unavailable,
key_counter,
@ -34,12 +36,14 @@ if TYPE_CHECKING:
from litellm.redis.stores.connection import RedisStores
__all__ = [
"COMMITTED_COUNTER_PREFIX",
"END_USER_COUNTER_PREFIX",
"SPEND_COUNTERS_TARGET",
"SPEND_COUNTER_PREFIX",
"SpendCounterStore",
"SpendIncrement",
"SpendReading",
"committed_counter",
"end_user_counter",
"is_unavailable",
"key_counter",
@ -81,6 +85,11 @@ class SpendCounterStore:
def has_redis(self) -> bool:
return self._handle.has_redis
def set_committed_ttl(self, ttl: float) -> None:
"""The least TTL a Redis write gives a committed twin, which keeps it past the DB write that holds its
charges."""
self._handle.set_committed_ttl(ttl)
@with_service_target(SPEND_COUNTERS_TARGET)
async def read(self, counter: str) -> SpendReading:
reading: Final = await self._handle.read(counter)
@ -104,12 +113,13 @@ class SpendCounterStore:
return tuple(await self._handle.increment_many(increments))
@with_service_target(SPEND_COUNTERS_TARGET)
def increment_many_post_call(self, increments: Sequence[SpendIncrement]) -> bool:
"""Declares the increments on the request's post-call batch, False when there is no Redis or request.
Each total lands in memory once the batch goes out; a counter whose increment failed is dropped everywhere."""
def increment_many_post_call(self, increments: Sequence[SpendIncrement]) -> PostCallSettlement | None:
"""Declares the increments on the request's post-call batch, None when there is no Redis or request.
Each total lands in memory once the batch goes out; a counter whose increment failed is dropped everywhere.
The settlement reports when every reply is back, whoever sent the batch."""
request: Final = post_call_request() if self.has_redis and increments else None
if request is None:
return False
return None
return self._handle.increment_many_post_call(request, increments)
@with_service_target(SPEND_COUNTERS_TARGET)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Mapping, Sequence
from typing import Protocol
from litellm.caching.redis_batch import NativePending
from litellm.caching.redis_batch import NativePending, PostCallSettlement
from litellm.redis.stores.spend_counters.types import SpendIncrement
from litellm.rust_bridge._native import NativeRedisClient, NativeRequestRedis, NativeSend, SpendRead
@ -13,6 +13,7 @@ class SpendCountersHandle(Protocol):
def has_redis(self) -> bool: ...
@property
def redis_ttl_seconds(self) -> float | None: ...
def set_committed_ttl(self, ttl_seconds: float, /) -> None: ...
def attach(self, client: NativeRedisClient, /) -> bool: ...
def read(self, counter: str) -> Awaitable[SpendRead]: ...
def read_many(
@ -20,7 +21,9 @@ class SpendCountersHandle(Protocol):
) -> NativePending[Sequence[float | None] | None] | None: ...
def increment(self, counter: str, amount: float) -> Awaitable[float]: ...
def increment_many(self, increments: Sequence[SpendIncrement]) -> Awaitable[Sequence[float]]: ...
def increment_many_post_call(self, request: NativeRequestRedis, increments: Sequence[SpendIncrement]) -> bool: ...
def increment_many_post_call(
self, request: NativeRequestRedis, increments: Sequence[SpendIncrement]
) -> PostCallSettlement | None: ...
def set_max(self, counter: str, spend: float) -> Awaitable[float | None]: ...
def seed(self, counter: str, spend: float, *, increment_if_lost: bool) -> Awaitable[float]: ...
def set(self, counter: str, spend: float, ttl_seconds: float | None = None) -> Awaitable[None]: ...

View file

@ -7,6 +7,7 @@ from litellm.rust_bridge._native import NativeRedisStoreError
SPEND_COUNTERS_TARGET: Final = "spend_counters"
SPEND_COUNTER_PREFIX: Final = "spend:"
END_USER_COUNTER_PREFIX: Final = "spend:end_user:"
COMMITTED_COUNTER_PREFIX: Final = "spend:committed:"
SpendIncrement: TypeAlias = tuple[str, float]
SpendReading: TypeAlias = tuple[float | None, bool]
@ -61,6 +62,11 @@ def shadow_eval_counter(job_id: str) -> str:
return f"spend:shadow_eval:{job_id}"
def committed_counter(counter_key: str) -> str:
"""The charges-only twin of a key, team, user, org or end user lifetime counter."""
return f"{COMMITTED_COUNTER_PREFIX}{counter_key.removeprefix(SPEND_COUNTER_PREFIX)}"
def is_unavailable(error: BaseException) -> bool:
"""Redis timed out or its breaker is open, the failures a post-call increment drops."""
return isinstance(error, NativeRedisStoreError) and error.args[:1] in (("timeout",), ("breaker_open",))

View file

@ -358,6 +358,11 @@ class SpendRead:
@property
def from_redis(self) -> bool: ...
@final
class PostCallSettlement:
@property
def settled(self) -> bool: ...
@final
class NativeSpendCounters:
def __new__(cls, client: NativeRedisClient | None) -> NativeSpendCounters: ...
@ -366,6 +371,7 @@ class NativeSpendCounters:
def has_redis(self) -> bool: ...
@property
def redis_ttl_seconds(self) -> float | None: ...
def set_committed_ttl(self, ttl_seconds: float) -> None: ...
def read(self, counter: str) -> Future[SpendRead]: ...
def read_many(
self, counters: Sequence[str], send: NativeSend
@ -374,7 +380,7 @@ class NativeSpendCounters:
def increment_many(self, increments: Sequence[tuple[str, float]]) -> Future[list[float]]: ...
def increment_many_post_call(
self, request: NativeRequestRedis, increments: Sequence[tuple[str, float]]
) -> bool: ...
) -> PostCallSettlement | None: ...
def set_max(self, counter: str, spend: float) -> Future[float | None]: ...
def seed(self, counter: str, spend: float, *, increment_if_lost: bool) -> Future[float]: ...
def set(self, counter: str, spend: float, ttl_seconds: float | None = None) -> Future[None]: ...
@ -596,6 +602,7 @@ __all__ = [
"NativeSend",
"NativeSensitiveRoutePins",
"NativeSpendCounters",
"PostCallSettlement",
"ProcessReservedForForking",
"ResponsesWebSocketConnection",
"RouterCall",

View file

@ -16,6 +16,7 @@ from litellm.types.agents import AgentCaller, AgentResponse
from litellm.types.proxy.agent_identity import ManagedAgentContext
from litellm.types.proxy.auth.user_roles import LitellmUserRoles
from litellm.types.proxy.carried_budget_state import (
CommittedBudgetSpend,
OrgBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
@ -206,6 +207,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
team_budget_snapshot: TeamBudgetSnapshot | None = Field(default=None, exclude=True)
user_budget_snapshot: UserBudgetSnapshot | None = Field(default=None, exclude=True)
org_budget_snapshot: OrgBudgetSnapshot | None = Field(default=None, exclude=True)
committed_budget_spend: CommittedBudgetSpend | None = Field(default=None, exclude=True, repr=False)
matched_model_access_groups: list[str] | None = Field(default=None, exclude=True)
budget_throttle_pct: float | None = Field(default=None, exclude=True)
user: object | None = None # Expanded user object when expand=user is used
@ -329,6 +331,21 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
)
COMMITTED_BUDGET_SPEND_PARAM: Final = "committed_budget_spend"
def committed_budget_spend_of_row(
metadata: Mapping[str, object], litellm_params: Mapping[str, object]
) -> CommittedBudgetSpend | None:
auth: Final = metadata.get("user_api_key_auth")
if isinstance(auth, UserAPIKeyAuth) and auth.committed_budget_spend is not None:
return auth.committed_budget_spend
if "user_api_key_auth" in metadata:
return CommittedBudgetSpend()
carried: Final = litellm_params.get(COMMITTED_BUDGET_SPEND_PARAM)
return carried if isinstance(carried, CommittedBudgetSpend) else None
def user_api_key_has_admin_view(user_api_key_dict: UserAPIKeyAuth) -> bool:
"""Return True if the caller's role grants unscoped read access to all
tenant resources (managed files, batches, vector stores, spend rows, etc).

View file

@ -9,10 +9,18 @@ reads them back with ``from_metadata``. ``None`` means this request never carrie
from collections.abc import Mapping
from datetime import datetime
from types import MappingProxyType
from typing import Final
from pydantic import ConfigDict, Field, ValidationError
from typing_extensions import Self
from litellm.redis.stores.spend_counters.types import (
end_user_counter,
key_counter,
org_counter,
team_counter,
user_counter,
)
from litellm.types.llms.base import LiteLLMBaseModel
@ -25,7 +33,7 @@ class _BudgetSnapshot(LiteLLMBaseModel):
@classmethod
def from_metadata(cls, metadata: Mapping[str, object]) -> Self | None:
try:
return cls.model_validate(metadata)
return cls.model_validate(metadata, by_name=False)
except ValidationError:
return None
@ -64,3 +72,51 @@ class OrgBudgetSnapshot(_BudgetSnapshot):
max_budget: float | None = Field(
validation_alias="user_api_key_org_max_budget", serialization_alias="user_api_key_org_max_budget"
)
class EntitySpendCounterKeys(LiteLLMBaseModel):
"""Spend counter of each entity whose remaining budget a gauge reports. ``None`` for an entity with no budget."""
model_config = ConfigDict(frozen=True)
key: str | None = None
team: str | None = None
user: str | None = None
org: str | None = None
end_user: str | None = None
@classmethod
def of(
cls,
token: str | None,
team_id: str | None,
user_id: str | None,
org_id: str | None,
end_user_id: str | None,
) -> Self:
return cls(
key=key_counter(token) if token else None,
team=team_counter(team_id) if team_id else None,
user=user_counter(user_id) if user_id else None,
org=org_counter(org_id) if org_id else None,
end_user=end_user_counter(end_user_id) if end_user_id else None,
)
def tracked(self) -> tuple[str, ...]:
counter_keys: Final = (self.key, self.team, self.user, self.org, self.end_user)
return tuple(counter_key for counter_key in counter_keys if counter_key is not None)
class CommittedBudgetSpend(LiteLLMBaseModel):
"""Spend each entity was charged, budget holds excluded, read from the committed spend counters for the
remaining budget gauges only. ``None`` when the entity has no committed counter. ``counter_keys`` names the
budgeted entities whose committed counters this request charges and reads."""
model_config = ConfigDict(frozen=True)
counter_keys: EntitySpendCounterKeys = Field(default=EntitySpendCounterKeys(), repr=False)
key: float | None = Field(default=None, repr=False)
team: float | None = Field(default=None, repr=False)
user: float | None = Field(default=None, repr=False)
org: float | None = Field(default=None, repr=False)
end_user: float | None = Field(default=None, repr=False)

View file

@ -424,7 +424,7 @@ async def test_spend_counter_increments_ride_the_native_post_call_batch(monkeypa
with request_redis_batch_scope(native_factory=natives, native_send_factory=fake_send): # pyright: ignore[reportArgumentType] # test double
await proxy_server._apply_spend_counter_increments(pending)
declared = list(spend.post_call)
declared = [(request, increments) for request, increments, _ in spend.post_call]
assert natives.built[0].events == []
await flush_post_call_redis_batches()
@ -433,6 +433,27 @@ async def test_spend_counter_increments_ride_the_native_post_call_batch(monkeypa
assert spend.commands == []
@pytest.mark.asyncio
async def test_the_request_sees_its_committed_spend_charge_settle_whoever_sends_the_batch(monkeypatch):
from litellm.proxy import proxy_server
spend = FakeSpendCounters(redis={})
monkeypatch.setattr(proxy_server, "spend_counter_store", spend.store())
with request_redis_batch_scope(native_factory=FakeNatives(), native_send_factory=fake_send) as request: # pyright: ignore[reportArgumentType] # test double
await proxy_server._apply_spend_counter_increments([PendingSpendIncrement("spend:key:k1", 0.5)])
without_committed_spend = request.post_call_settlements
await proxy_server._apply_spend_counter_increments(
[PendingSpendIncrement("spend:key:k1", 0.5), PendingSpendIncrement("spend:committed:key:k1", 0.5)]
)
declared = [settlement.settled for settlement in request.post_call_settlements]
spend.settle_post_call()
sent = [settlement.settled for settlement in request.post_call_settlements]
assert without_committed_spend == ()
assert (declared, sent) == ([False], [True])
@pytest.mark.asyncio
async def test_native_post_call_ops_go_out_with_the_post_call_flush_not_the_request_boundary():
natives = FakeNatives()

View file

@ -20,6 +20,8 @@ from litellm.integrations.gcs_pubsub.pub_sub import *
from litellm.integrations.generic_api.generic_api_callback import LOG_FORMAT_TYPES, GenericAPILogger
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
from litellm.types.utils import StandardLoggingPayload
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@ -795,3 +797,27 @@ async def test_generic_api_concurrent_flushes_deliver_each_event_once(log_format
task.cancel()
await asyncio.gather(*owned_tasks, return_exceptions=True)
await handler.close()
@pytest.mark.asyncio
async def test_v1_payload_of_a_chat_row_never_shows_the_committed_spend_auth_read() -> None:
counter_keys: Final = EntitySpendCounterKeys.of(
token="hashed-token", team_id="t1", user_id=None, org_id=None, end_user_id=None
)
auth: Final = UserAPIKeyAuth(
api_key="hashed-token",
team_id="t1",
committed_budget_spend=CommittedBudgetSpend(counter_keys=counter_keys, key=0.123456, team=0.654321),
)
generic_logger: Final = GenericAPILogger(endpoint="https://example.com/api/logs", flush_interval=3600)
payload: Final = generic_logger._get_v1_logging_payload(
kwargs={"litellm_params": {"metadata": {"user_api_key_auth": auth}}},
response_obj={"usage": {}},
start_time=None,
end_time=None,
)
assert "team_id='t1'" in payload["metadata"]
assert "0.123456" not in payload["metadata"]
assert str(counter_keys.team) not in payload["metadata"]

View file

@ -4,13 +4,22 @@ from request metadata. get_*_object only runs when that state is missing (custom
auth, SDK callers, failure paths)
"""
import asyncio
from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from prometheus_client import REGISTRY
from litellm.caching.redis_batch import (
active_post_call_redis_batch,
flush_post_call_redis_batches,
request_redis_batch_scope,
)
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy import proxy_server
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
@ -24,11 +33,16 @@ from litellm.proxy.spend_tracking.carried_budget_state import (
carry_organization_budget_state,
carry_team_and_user_budget_state,
)
from litellm.proxy.spend_tracking.spend_counter_batch import PendingSpendIncrement
from litellm.types.proxy.carried_budget_state import (
CommittedBudgetSpend,
EntitySpendCounterKeys,
KeyBudgetSnapshot,
TeamBudgetSnapshot,
UserBudgetSnapshot,
)
from tests.unit.caching.test_redis_batch import FakeNatives, fake_send, make
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
TEAM_RESET_AT = datetime(2026, 10, 1, tzinfo=timezone.utc)
USER_RESET_AT = datetime(2026, 11, 1, tzinfo=timezone.utc)
@ -55,6 +69,15 @@ def prometheus_logger():
return PrometheusLogger()
@pytest.fixture(autouse=True)
def spend_counters():
spend: Final = FakeSpendCounters(redis=None)
with patch( # test-quality-ok: prometheus reads the proxy_server spend counters at emit time, no injection seam
"litellm.proxy.proxy_server.spend_counter_store", spend.store()
):
yield spend
@pytest.fixture
def getters():
"""Every response-path object getter, patched where prometheus imports them from."""
@ -137,6 +160,7 @@ def _request_metadata(token: UserAPIKeyAuth) -> dict:
return {
**LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(token),
**carried_budget_metadata(token),
"user_api_key_auth": token,
}
@ -198,6 +222,499 @@ async def test_authed_request_sets_every_gauge_without_any_object_getter(prometh
prometheus_logger.litellm_user_budget_remaining_hours_metric.labels().set.assert_called_once()
_COUNTER_KEYS: Final = ("spend:key:hashed", "spend:team:t1", "spend:user:u1", "spend:org:o1")
_TWIN_KEYS: Final = (
"spend:committed:key:hashed",
"spend:committed:team:t1",
"spend:committed:user:u1",
"spend:committed:org:o1",
)
def _store(spend: FakeSpendCounters, counter_keys: Sequence[str], values: Sequence[float]) -> None:
tier: Final = spend.memory if spend.redis is None else spend.redis
tier.update(zip(counter_keys, values, strict=True))
def _store_counters(spend: FakeSpendCounters, key: float, team: float, user: float, org: float) -> None:
_store(spend, _COUNTER_KEYS, (key, team, user, org))
def _store_committed(spend: FakeSpendCounters, key: float, team: float, user: float, org: float) -> None:
_store(spend, _TWIN_KEYS, (key, team, user, org))
def _remaining(prometheus_logger: PrometheusLogger) -> dict[str, float]:
return {
"key": prometheus_logger.litellm_remaining_api_key_budget_metric.labels().set.call_args[0][0],
"team": prometheus_logger.litellm_remaining_team_budget_metric.labels().set.call_args[0][0],
"user": prometheus_logger.litellm_remaining_user_budget_metric.labels().set.call_args[0][0],
"org": prometheus_logger.litellm_remaining_org_budget_metric.labels().set.call_args[0][0],
}
def _token_committed_at_auth() -> UserAPIKeyAuth:
token = _authed_token()
token.committed_budget_spend = CommittedBudgetSpend(
counter_keys=EntitySpendCounterKeys.of(
token="hashed", team_id="t1", user_id="u1", org_id="o1", end_user_id=None
),
key=3.0,
team=30.0,
user=8.0,
org=60.0,
)
return token
@pytest.mark.asyncio
async def test_gauges_use_the_committed_spend_at_emit_when_other_requests_finished_after_auth(
prometheus_logger, getters, spend_counters
):
"""This request's 2.0 plus 1.0 from another request that finished first, so the gauge is never set back."""
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=6.0, team=33.0, user=11.0, org=63.0)
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(4.0),
"team": pytest.approx(267.0),
"user": pytest.approx(39.0),
"org": pytest.approx(437.0),
}
@pytest.mark.asyncio
async def test_gauges_add_the_cost_to_another_requests_charge_while_the_own_charge_is_not_in_yet(
prometheus_logger, getters, spend_counters
):
"""1.0 from another request reached the counters during this long request, this request's 2.0 has not."""
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=4.0, team=31.0, user=9.0, org=61.0)
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(4.0),
"team": pytest.approx(267.0),
"user": pytest.approx(39.0),
"org": pytest.approx(437.0),
}
@pytest.mark.asyncio
async def test_gauges_read_no_committed_counter_auth_did_not_name_as_budgeted(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=9.0, team=99.0, user=19.0, org=99.0)
await _emit(prometheus_logger, _request_metadata(_authed_token()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(7.0),
"team": pytest.approx(278.0),
"user": pytest.approx(43.0),
"org": 458.0,
}
def _billed_row_metadata() -> dict:
return {key: value for key, value in _request_metadata(_authed_token()).items() if key != "user_api_key_auth"}
@pytest.mark.asyncio
async def test_row_with_no_auth_adds_its_cost_to_the_committed_spend_of_the_entities_it_is_charged_to(
prometheus_logger, getters, spend_counters
):
"""Counter increments flush after the success callbacks, so the row's own charge is not in the counters yet."""
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=6.0, team=33.0, user=11.0, org=63.0)
await _emit(prometheus_logger, _billed_row_metadata())
assert _remaining(prometheus_logger) == {
"key": pytest.approx(2.0),
"team": pytest.approx(265.0),
"user": pytest.approx(37.0),
"org": 435.0,
}
@pytest.mark.asyncio
async def test_row_with_no_auth_and_no_committed_counters_keeps_the_stored_spend(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
await _emit(prometheus_logger, _billed_row_metadata())
assert _remaining(prometheus_logger) == {
"key": pytest.approx(7.0),
"team": pytest.approx(278.0),
"user": pytest.approx(43.0),
"org": 458.0,
}
@pytest.mark.asyncio
async def test_slow_committed_read_on_a_failed_request_is_bounded_and_keeps_the_auth_time_spend(
prometheus_logger, getters, monkeypatch
):
async def never_answers(counter: str) -> None:
await asyncio.sleep(30)
monkeypatch.setattr(
"litellm.proxy.proxy_server.spend_counter_store",
FakeSpendCounters(redis={}, batch_reads=False, read_hook=never_answers).store(),
)
monkeypatch.setenv("PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT", "0.01")
_stub_gauges(prometheus_logger)
await asyncio.wait_for(
prometheus_logger.async_log_failure_event(
_failed_request(_request_metadata(_token_committed_at_auth())), None, KEY_RESET_AT, KEY_RESET_AT
),
timeout=5,
)
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(pytest.approx(440.0))
@pytest.mark.asyncio
async def test_slow_committed_read_on_a_successful_request_still_sets_the_gauges_from_the_auth_time_spend(
prometheus_logger, getters, monkeypatch
):
async def never_answers(counter: str) -> None:
await asyncio.sleep(30)
monkeypatch.setattr(
"litellm.proxy.proxy_server.spend_counter_store",
FakeSpendCounters(redis={}, batch_reads=False, read_hook=never_answers).store(),
)
monkeypatch.setenv("PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT", "0.5")
_stub_gauges(prometheus_logger)
await asyncio.wait_for(_emit(prometheus_logger, _request_metadata(_token_committed_at_auth())), timeout=5)
assert _remaining(prometheus_logger) == {
"key": pytest.approx(5.0),
"team": pytest.approx(268.0),
"user": pytest.approx(40.0),
"org": pytest.approx(438.0),
}
@pytest.fixture
def redis_spend_counters():
spend: Final = FakeSpendCounters(redis={})
with patch( # test-quality-ok: prometheus reads the proxy_server spend counters at emit time, no injection seam
"litellm.proxy.proxy_server.spend_counter_store", spend.store()
):
yield spend
def _request_scope() -> request_redis_batch_scope:
return request_redis_batch_scope(
native_factory=FakeNatives(), # pyright: ignore[reportArgumentType] # test double
native_send_factory=fake_send, # pyright: ignore[reportArgumentType] # test double
)
async def _declare_the_own_two_dollar_charge() -> None:
await proxy_server._apply_spend_counter_increments(
tuple(PendingSpendIncrement(counter_key, 2.0) for counter_key in (*_COUNTER_KEYS, *_TWIN_KEYS))
)
_BOTH_TWO_DOLLAR_CHARGES_COUNTED_ONCE: Final = {
"key": pytest.approx(3.0),
"team": pytest.approx(266.0),
"user": pytest.approx(38.0),
"org": pytest.approx(436.0),
}
@pytest.mark.asyncio
async def test_gauges_add_the_own_charge_to_a_same_size_charge_of_another_request_while_the_own_waits_in_the_pipeline(
prometheus_logger, getters, redis_spend_counters
):
"""Another request's 2.0 reached the counters and this request's 2.0 is queued, so both count."""
_stub_gauges(prometheus_logger)
_store_committed(redis_spend_counters, key=5.0, team=32.0, user=10.0, org=62.0)
with _request_scope():
await _declare_the_own_two_dollar_charge()
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
await flush_post_call_redis_batches()
assert _remaining(prometheus_logger) == _BOTH_TWO_DOLLAR_CHARGES_COUNTED_ONCE
@pytest.mark.asyncio
async def test_gauges_do_not_add_the_own_charge_twice_once_the_pipeline_went_out(
prometheus_logger, getters, redis_spend_counters
):
"""The 2.0 over the auth time spend is this request's own charge, settled before the gauges read it."""
_stub_gauges(prometheus_logger)
_store_committed(redis_spend_counters, key=3.0, team=30.0, user=8.0, org=60.0)
with _request_scope():
await _declare_the_own_two_dollar_charge()
redis_spend_counters.settle_post_call()
await flush_post_call_redis_batches()
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(5.0),
"team": pytest.approx(268.0),
"user": pytest.approx(40.0),
"org": pytest.approx(438.0),
}
@pytest.mark.asyncio
async def test_an_unrelated_post_call_flush_does_not_make_the_gauge_drop_its_own_unsent_charge(
prometheus_logger, getters, redis_spend_counters
):
"""An unrelated write went out first, then another request's 2.0 reached the counters. This request's 2.0 counts
once whether it is undeclared, declared but unsent, or settled."""
_stub_gauges(prometheus_logger)
_store_committed(redis_spend_counters, key=5.0, team=32.0, user=10.0, org=62.0)
unrelated_cache, _ = make()
metadata: Final = _request_metadata(_token_committed_at_auth())
with _request_scope():
active_post_call_redis_batch(unrelated_cache).increment("unrelated-write", 1.0)
await flush_post_call_redis_batches()
await _emit(prometheus_logger, metadata)
undeclared: Final = _remaining(prometheus_logger)
await _declare_the_own_two_dollar_charge()
await _emit(prometheus_logger, metadata)
declared_unsent: Final = _remaining(prometheus_logger)
redis_spend_counters.settle_post_call()
await _emit(prometheus_logger, metadata)
settled: Final = _remaining(prometheus_logger)
await flush_post_call_redis_batches()
assert {"undeclared": undeclared, "declared_unsent": declared_unsent, "settled": settled} == {
"undeclared": _BOTH_TWO_DOLLAR_CHARGES_COUNTED_ONCE,
"declared_unsent": _BOTH_TWO_DOLLAR_CHARGES_COUNTED_ONCE,
"settled": _BOTH_TWO_DOLLAR_CHARGES_COUNTED_ONCE,
}
@pytest.mark.asyncio
async def test_gauges_count_the_own_charge_once_when_it_settles_while_the_gauges_read_the_counters(
prometheus_logger, getters, redis_spend_counters
):
"""Another request's 2.0 reached the counters, and this request's 2.0 goes out during the gauges' read."""
_stub_gauges(prometheus_logger)
_store_committed(redis_spend_counters, key=5.0, team=32.0, user=10.0, org=62.0)
async def settle_during_read(_counter: str) -> None:
redis_spend_counters.settle_post_call()
redis_spend_counters.batch_reads = False
redis_spend_counters.read_hook = settle_during_read
with _request_scope():
await _declare_the_own_two_dollar_charge()
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
await flush_post_call_redis_batches()
assert _remaining(prometheus_logger) == _BOTH_TWO_DOLLAR_CHARGES_COUNTED_ONCE
@pytest.mark.asyncio
async def test_gauges_without_redis_treat_a_charge_equal_to_the_own_as_the_own(
prometheus_logger, getters, spend_counters
):
"""Without Redis the charge increments in memory at once, so a request scope says nothing about it."""
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=5.0, team=32.0, user=10.0, org=62.0)
with request_redis_batch_scope():
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(5.0),
"team": pytest.approx(268.0),
"user": pytest.approx(40.0),
"org": pytest.approx(438.0),
}
@pytest.mark.asyncio
async def test_gauges_add_the_cost_to_the_auth_time_spend_before_the_charge_reaches_the_counters(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=3.0, team=30.0, user=8.0, org=60.0)
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(5.0),
"team": pytest.approx(268.0),
"user": pytest.approx(40.0),
"org": pytest.approx(438.0),
}
@pytest.mark.asyncio
async def test_gauges_keep_the_auth_time_spend_when_the_proxy_spend_counters_cannot_be_read(
prometheus_logger, getters, monkeypatch
):
_stub_gauges(prometheus_logger)
monkeypatch.delattr("litellm.proxy.proxy_server.spend_counter_store")
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(5.0),
"team": pytest.approx(268.0),
"user": pytest.approx(40.0),
"org": pytest.approx(438.0),
}
@pytest.mark.asyncio
async def test_a_budget_hold_on_the_enforcement_counters_never_shows_in_the_gauges(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=5.0, team=32.0, user=10.0, org=62.0)
_store_counters(spend_counters, key=9.0, team=36.0, user=14.0, org=66.0)
await _emit(prometheus_logger, _request_metadata(_token_committed_at_auth()))
assert _remaining(prometheus_logger) == {
"key": pytest.approx(5.0),
"team": pytest.approx(268.0),
"user": pytest.approx(40.0),
"org": pytest.approx(438.0),
}
def _failed_request(metadata: dict, response_cost: float = 0.0) -> dict:
return {
"model": "openai/gpt-5.4-nano",
"litellm_params": {"metadata": metadata},
"standard_logging_object": {
"model": "openai/gpt-5.4-nano",
"model_id": "dep-1",
"model_group": "chat",
"api_base": "https://api.openai.com",
"custom_llm_provider": "openai",
"status": "failure",
"response_cost": response_cost,
"request_tags": [],
"hidden_params": {"litellm_overhead_time_ms": None, "additional_headers": None},
"metadata": {
"user_api_key_hash": "hashed",
"user_api_key_alias": "key-alias",
"user_api_key_team_id": "t1",
"user_api_key_team_alias": "team-alias",
"user_api_key_user_id": "u1",
"user_api_key_org_id": "o1",
"requester_metadata": None,
"user_api_key_end_user_id": None,
},
},
"exception": Exception("upstream failed"),
}
@pytest.mark.asyncio
async def test_failed_request_sets_the_org_gauge_from_the_carried_org_and_committed_spend(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=3.0, team=30.0, user=8.0, org=65.0)
_store_counters(spend_counters, key=9.0, team=36.0, user=14.0, org=90.0)
await prometheus_logger.async_log_failure_event(
_failed_request(_request_metadata(_token_committed_at_auth())), None, KEY_RESET_AT, KEY_RESET_AT
)
assert getters["get_org_object"].await_count == 0
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(pytest.approx(435.0))
@pytest.mark.asyncio
async def test_failed_request_without_the_auth_object_still_reads_the_committed_org_spend(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=3.0, team=30.0, user=8.0, org=65.0)
metadata = _request_metadata(_token_committed_at_auth())
del metadata["user_api_key_auth"]
await prometheus_logger.async_log_failure_event(_failed_request(metadata), None, KEY_RESET_AT, KEY_RESET_AT)
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(pytest.approx(435.0))
@pytest.mark.asyncio
async def test_failed_request_without_a_carried_org_reads_no_committed_org_spend(
prometheus_logger, getters, spend_counters
):
_stub_gauges(prometheus_logger)
_store_committed(spend_counters, key=3.0, team=30.0, user=8.0, org=65.0)
token = _token_committed_at_auth().model_copy(update={"org_budget_snapshot": None})
metadata = {key: value for key, value in _request_metadata(token).items() if key != "user_api_key_auth"}
await prometheus_logger.async_log_failure_event(_failed_request(metadata), None, KEY_RESET_AT, KEY_RESET_AT)
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(pytest.approx(460.0))
@pytest.mark.asyncio
async def test_failed_request_that_was_charged_counts_its_own_charge_while_it_waits_in_the_pipeline(
prometheus_logger, getters, redis_spend_counters
):
"""A stream cut after 40 events still costs 2.0, and that charge is queued when the failure logs."""
_stub_gauges(prometheus_logger)
_store_committed(redis_spend_counters, key=5.0, team=32.0, user=10.0, org=62.0)
with _request_scope():
await _declare_the_own_two_dollar_charge()
await prometheus_logger.async_log_failure_event(
_failed_request(_request_metadata(_token_committed_at_auth()), response_cost=2.0),
None,
KEY_RESET_AT,
KEY_RESET_AT,
)
await flush_post_call_redis_batches()
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(pytest.approx(436.0))
@pytest.mark.asyncio
async def test_ordinary_metadata_keys_named_like_snapshot_fields_change_no_gauge(prometheus_logger, getters):
_stub_gauges(prometheus_logger)
metadata = {
"user_api_key_team_spend": 20.0,
"user_api_key_team_max_budget": 300.0,
"spend": 1000.0,
"max_budget": 1.0,
"budget_reset_at": None,
"user_alias": "not-the-user",
}
await _emit(prometheus_logger, metadata)
assert {name: getter.await_count for name, getter in getters.items()} == {
"get_key_object": 1,
"get_team_object": 1,
"get_user_object": 1,
"get_org_object": 1,
}
prometheus_logger.litellm_remaining_org_budget_metric.labels().set.assert_called_once_with(458.0)
@pytest.mark.asyncio
async def test_metadata_without_carried_state_still_fetches_each_object_once(prometheus_logger, getters):
_stub_gauges(prometheus_logger)

View file

@ -1065,6 +1065,79 @@ async def test_set_customer_budget_metrics_after_api_request_uses_cached_default
assert _customer_sample("litellm_customer_max_budget_metric", "cust-default") == pytest.approx(3.0)
@pytest.mark.asyncio
async def test_customer_gauge_subtracts_the_committed_spend_instead_of_the_cached_spend(
prometheus_logger, customer_metrics_enabled
):
"""Auth saw 1.5 committed, and by emit the counter holds 2.5 with this request's 0.5 and another request's."""
import sys
from litellm.models.budget import LiteLLM_BudgetTable
from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
mock_proxy_server = MagicMock()
mock_proxy_server.user_api_key_cache.async_get_cache = AsyncMock(
return_value=LiteLLM_EndUserTable(
user_id="cust-live",
blocked=False,
spend=0.5,
litellm_budget_table=LiteLLM_BudgetTable(budget_id="b", max_budget=3.0),
)
)
mock_proxy_server.spend_counter_store = FakeSpendCounters(
redis=None, memory={"spend:committed:end_user:cust-live": 2.5}
).store()
token = UserAPIKeyAuth(token="hashed")
token.committed_budget_spend = CommittedBudgetSpend(
counter_keys=EntitySpendCounterKeys(end_user="spend:end_user:cust-live"), end_user=1.5
)
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
await prometheus_logger._increment_remaining_budget_metrics(
user_api_team=None,
user_api_team_alias=None,
user_api_key=None,
user_api_key_alias=None,
litellm_params={"metadata": {"user_api_key_auth": token}},
response_cost=0.5,
end_user_id="cust-live",
)
assert _customer_sample("litellm_remaining_customer_budget_metric", "cust-live") == pytest.approx(0.5)
@pytest.mark.asyncio
async def test_customer_gauge_counts_a_charge_the_cached_customer_row_already_holds_once(
prometheus_logger, customer_metrics_enabled
):
import sys
from litellm.models.budget import LiteLLM_BudgetTable
from litellm.models.end_user import LiteLLM_EndUserTable
mock_proxy_server = MagicMock()
mock_proxy_server.user_api_key_cache.async_get_cache = AsyncMock(
return_value=LiteLLM_EndUserTable(
user_id="cust-charged",
blocked=False,
spend=0.5,
litellm_budget_table=LiteLLM_BudgetTable(budget_id="b", max_budget=3.0),
)
)
with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}):
await prometheus_logger._set_customer_budget_metrics_after_api_request(
end_user_id="cust-charged",
response_cost=0.5,
committed_spend=0.0,
)
assert _customer_sample("litellm_remaining_customer_budget_metric", "cust-charged") == pytest.approx(2.5)
@pytest.mark.asyncio
async def test_set_customer_budget_metrics_after_api_request_without_budget_only_emits_remaining(
prometheus_logger, customer_metrics_enabled

View file

@ -5094,6 +5094,70 @@ async def test_centralized_common_checks_carries_team_and_user_budget_state_on_t
}
@pytest.mark.asyncio
@pytest.mark.parametrize("prometheus_registered", [True, False], ids=["prometheus_logger", "no_prometheus_logger"])
async def test_centralized_common_checks_carry_the_committed_spend_beside_the_stale_cached_spend(
monkeypatch: pytest.MonkeyPatch, prometheus_registered: bool
):
"""The enforcement counters also hold budget holds, the committed twins only charged spend."""
from starlette.datastructures import URL
import litellm.proxy.proxy_server as _proxy_server_mod
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy.spend_tracking.committed_spend import committed_spend_key
from litellm.proxy.utils import ProxyLogging
from litellm.redis.stores.connection import connect
from litellm.redis.stores.spend_counters import SpendCounterStore
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
for callback_list in ("success_callback", "failure_callback", "_async_success_callback", "_async_failure_callback"):
monkeypatch.setattr(litellm, callback_list, [])
monkeypatch.setattr(litellm, "callbacks", [MagicMock(spec=PrometheusLogger)] if prometheus_registered else [])
token: Final = UserAPIKeyAuth(api_key="sk-live-spend", max_budget=10.0, spend=0.0, team_id="t-live", team_spend=0.0)
user_api_key_cache: Final = UserApiKeyCache()
user_api_key_cache.set_cache(
key="team_id:t-live", value=LiteLLM_TeamTableCachedObj(team_id="t-live", max_budget=20.0, spend=0.0)
)
spend_counter_store: Final = SpendCounterStore(connect(None).spend_counters())
for counter_key, held, committed in ((f"spend:key:{token.token}", 0.67, 0.37), ("spend:team:t-live", 1.55, 1.25)):
spend_counter_store.set_local(counter_key, held)
spend_counter_store.set_local(committed_spend_key(counter_key), committed)
attrs: Final = {
**_proxy_attrs_for_centralized_checks(),
"llm_router": None,
"prisma_client": MagicMock(),
"user_api_key_cache": user_api_key_cache,
"spend_counter_store": spend_counter_store,
"proxy_logging_obj": ProxyLogging(user_api_key_cache=user_api_key_cache),
}
originals: Final = {attribute: getattr(_proxy_server_mod, attribute, None) for attribute in attrs}
request: Final = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
try:
for attribute, value in attrs.items():
setattr(_proxy_server_mod, attribute, value)
await run_centralized_common_checks(
user_api_key_auth_obj=token,
request=request,
request_data={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1000},
route="/chat/completions",
)
finally:
for attribute, value in originals.items():
setattr(_proxy_server_mod, attribute, value)
assert token.committed_budget_spend == (
CommittedBudgetSpend(
counter_keys=EntitySpendCounterKeys(key=f"spend:key:{token.token}", team=f"spend:team:{token.team_id}"),
key=0.37,
team=1.25,
)
if prometheus_registered
else None
)
assert (token.spend, token.team_spend) == (0.0, 0.0)
def _end_user_budget_row(budget_id: str, max_budget: float) -> MagicMock:
row = MagicMock()
row.dict = lambda: {"budget_id": budget_id, "max_budget": max_budget}

View file

@ -1438,7 +1438,47 @@ def test_reset_budget_for_keys_invalidates_redis_counter(reset_budget_job, mock_
asyncio.run(reset_budget_job.reset_budget_for_litellm_keys())
assert "spend:key:sk-abc" in counter_cache.deleted()
assert {"spend:key:sk-abc", "spend:committed:key:sk-abc"} <= counter_cache.deleted()
def _expired_key_row() -> object:
return type(
"Key",
(),
{
"spend": 100.0,
"budget_duration": "30d",
"budget_reset_at": datetime.now(timezone.utc),
"id": "key-1",
"token": "sk-abc",
},
)
def test_reset_budget_for_keys_without_redis_clears_the_counter_and_its_committed_twin(
reset_budget_job, mock_prisma_client, monkeypatch
):
counter_cache: Final = _make_counter_invalidation_job(monkeypatch)
counter_cache.redis = None
counter_cache.memory.update({"spend:key:sk-abc": 100.0, "spend:committed:key:sk-abc": 100.0})
mock_prisma_client.data["key"] = [_expired_key_row()]
asyncio.run(reset_budget_job.reset_budget_for_litellm_keys())
assert counter_cache.memory == {}
def test_a_redis_failure_on_the_key_reset_still_drops_its_committed_twin_locally(
reset_budget_job, mock_prisma_client, monkeypatch
):
counter_cache: Final = _make_counter_invalidation_job(monkeypatch)
counter_cache.failing = frozenset({"delete"})
counter_cache.memory.update({"spend:key:sk-abc": 100.0, "spend:committed:key:sk-abc": 100.0})
mock_prisma_client.data["key"] = [_expired_key_row()]
asyncio.run(reset_budget_job.reset_budget_for_litellm_keys())
assert counter_cache.memory == {}
def test_reset_budget_for_users_invalidates_redis_counter(reset_budget_job, mock_prisma_client, monkeypatch):
@ -1790,7 +1830,9 @@ def test_enduser_invalidation_is_paged_and_batched(reset_budget_job, mock_prisma
assert counter_cache.user_api_key_cache.async_delete_cache_keys.await_count == 6
invalidated: Final = counter_cache.deleted()
assert invalidated == {f"spend:end_user:cust-{i:06d}" for i in range(population)}
assert invalidated == {f"spend:end_user:cust-{i:06d}" for i in range(population)} | {
f"spend:committed:end_user:cust-{i:06d}" for i in range(population)
}
evicted: Final = {
key for call in counter_cache.user_api_key_cache.async_delete_cache_keys.await_args_list for key in call.args[0]
}

View file

@ -44,6 +44,7 @@ from litellm.proxy.utils import (
BilledFailedAttempt,
ProxyUpdateSpend,
)
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
from litellm.types.router import Deployment, LiteLLM_Params
from litellm.types.utils import CallTypes, LiteLLMBatch, ModelResponse, PromptTokensDetailsWrapper, Usage
from litellm.types.videos.main import VideoObject
@ -1129,6 +1130,95 @@ def test_get_budget_reservation_from_metadata_handles_dict_auth_object():
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("metadata", "expected_scope"),
[
(
{
"user_api_key_auth": UserAPIKeyAuth(
committed_budget_spend=CommittedBudgetSpend(
counter_keys=EntitySpendCounterKeys(key="spend:key:test_api_key")
)
)
},
EntitySpendCounterKeys(key="spend:key:test_api_key"),
),
({}, EntitySpendCounterKeys()),
],
)
async def test_charge_moves_only_the_committed_counters_auth_named_as_budgeted(metadata, expected_scope):
proxy_logging_obj = MagicMock()
proxy_logging_obj.db_spend_update_writer.update_database = AsyncMock()
increment_spend_counters = AsyncMock()
await _update_database_and_spend_counters(
proxy_logging_obj=proxy_logging_obj,
increment_spend_counters=increment_spend_counters,
user_api_key="test_api_key",
user_id="test_user_id",
end_user_id=None,
team_id="test_team_id",
org_id="test_org_id",
kwargs={"litellm_params": {"metadata": metadata}},
completion_response=None,
start_time=datetime.now(),
end_time=datetime.now(),
response_cost=0.2,
budget_reservation=None,
)
assert increment_spend_counters.await_args.kwargs["committed_spend_scope"] == expected_scope
@pytest.mark.asyncio
@pytest.mark.parametrize(
("tracked", "expected_key", "expected_org"),
[(True, pytest.approx(1.2), pytest.approx(2.2)), (False, 1.0, 2.0)],
)
async def test_charge_with_no_auth_on_its_row_moves_the_committed_counters_that_exist(
tracked, expected_key, expected_org
):
"""A background response is billed from its creator's stored attribution, after the request's auth is gone."""
from litellm.proxy.proxy_server import increment_spend_counters
proxy_logging_obj = MagicMock()
proxy_logging_obj.db_spend_update_writer.update_database = AsyncMock()
spend_counter_store: Final = SpendCounterStore(connect(None).spend_counters())
spend_counter_store.set_local("spend:committed:key:test_api_key", 1.0)
spend_counter_store.set_local("spend:committed:org:test_org_id", 2.0)
with (
patch("litellm.proxy.hooks.proxy_track_cost_callback.committed_spend_tracked", return_value=tracked),
patch("litellm.proxy.proxy_server.spend_counter_store", spend_counter_store),
patch(
"litellm.proxy.spend_tracking.committed_spend.SpendCounterReseed.from_db",
AsyncMock(return_value=50.0),
),
):
await _update_database_and_spend_counters(
proxy_logging_obj=proxy_logging_obj,
increment_spend_counters=increment_spend_counters,
user_api_key="test_api_key",
user_id="test_user_id",
end_user_id=None,
team_id="test_team_id",
org_id="test_org_id",
kwargs={"litellm_params": {"metadata": {"user_api_key_hash": "test_api_key"}}},
completion_response=None,
start_time=datetime.now(),
end_time=datetime.now(),
response_cost=0.2,
budget_reservation=None,
)
cached: Final = spend_counter_store.get_local
assert cached("spend:committed:key:test_api_key") == expected_key
assert cached("spend:committed:org:test_org_id") == expected_org
assert cached("spend:committed:team:test_team_id") is None
assert cached("spend:committed:user:test_user_id") is None
@pytest.mark.asyncio
async def test_update_database_and_spend_counters_releases_reservation_when_db_update_fails():
proxy_logging_obj = MagicMock()
@ -1257,6 +1347,7 @@ async def test_update_database_and_spend_counters_updates_counters_after_db_upda
request_started_at=start_time,
model_access_groups=("premium",),
project_id=None,
committed_spend_scope=EntitySpendCounterKeys(),
)
@ -2776,7 +2867,9 @@ _LIVE_COUNTERS: Final = {
}
async def _fail_request(logger: ProxyDBLogger, failure: Mapping[str, object]) -> None:
async def _fail_request(
logger: ProxyDBLogger, failure: Mapping[str, object], committed: CommittedBudgetSpend | None = None
) -> None:
await logger.async_post_call_failure_hook(
request_data={
"model": "gpt-4.1",
@ -2793,6 +2886,7 @@ async def _fail_request(logger: ProxyDBLogger, failure: Mapping[str, object]) ->
end_user_id="end-user-1",
org_id="org-1",
project_id="project-1",
committed_budget_spend=committed,
),
)
@ -2831,6 +2925,43 @@ async def test_failure_spend_rows_raise_the_live_spend_counters_by_their_cost(
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("failure", "charged"),
[
({"combined_usage_object": Usage(prompt_tokens=30), "response_cost": 0.125}, 0.125),
(_failure_with_billed_attempts(), 1.25),
({"standard_logging_object": {"cost_finalized": True, "response_cost": 0.125}}, 0.125),
],
ids=["recovered_stream_cost", "billed_retries", "completed_cost"],
)
async def test_a_failed_charge_moves_only_the_committed_counters_auth_named_as_budgeted(
live_spend_counters: SpendCounterStore, failure: Mapping[str, object], charged: float
):
"""The remaining budget gauges read the committed counters, so a charge missing from them shows as budget the
request never left behind, until the next reseed from the database."""
budgeted: Final = EntitySpendCounterKeys.of(
token="hashed-key", team_id="team-1", user_id=None, org_id=None, end_user_id=None
)
starting: Final = {
"spend:committed:key:hashed-key": 0.2,
"spend:committed:team:team-1": 0.3,
"spend:committed:user:user-1": 0.4,
}
for key, spend in starting.items():
live_spend_counters.set_local(key, spend)
await _fail_request(ProxyDBLogger(), failure, CommittedBudgetSpend(counter_keys=budgeted))
assert {key: live_spend_counters.get_local(key) for key in starting} == pytest.approx(
{
"spend:committed:key:hashed-key": 0.2 + charged,
"spend:committed:team:team-1": 0.3 + charged,
"spend:committed:user:user-1": 0.4,
}
)
@pytest.mark.asyncio
async def test_a_completed_cost_failure_raises_the_live_spend_counters_once(live_spend_counters: SpendCounterStore):
"""A failure whose cost was already finalized settles through the success path's write, which raises the

View file

@ -249,6 +249,29 @@ def test_delete_customer_not_found(mock_prisma_client, mock_user_api_key_auth):
assert response_json["error"]["code"] == "404"
def test_delete_customer_drops_its_committed_spend_so_a_recreated_customer_starts_from_its_own_row(
monkeypatch, mock_prisma_client, mock_user_api_key_auth
):
from types import SimpleNamespace
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
spend: Final = FakeSpendCounters(
redis=None,
memory={"spend:committed:end_user:cust-gone": 7.0, "spend:committed:end_user:cust-stays": 3.0},
)
monkeypatch.setattr("litellm.proxy.proxy_server.spend_counter_store", spend.store())
mock_prisma_client.db.litellm_endusertable.find_many = AsyncMock(return_value=[SimpleNamespace(user_id="cust-gone")])
mock_prisma_client.db.litellm_endusertable.delete_many = AsyncMock(return_value=1)
response: Final = client.post(
"/customer/delete", json={"user_ids": ["cust-gone"]}, headers={"Authorization": "Bearer test-key"}
)
assert response.status_code == 200
assert spend.memory == {"spend:committed:end_user:cust-stays": 3.0}
def test_error_schema_consistency(mock_prisma_client, mock_user_api_key_auth):
"""
Test that all customer endpoints return the same error schema format.

View file

@ -4222,7 +4222,10 @@ async def test_admin_user_update_spend_invalidates_counter(mocker):
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
mock_invalidate.assert_awaited_once_with(counter_key="spend:user:target-user")
assert [call.kwargs["counter_key"] for call in mock_invalidate.await_args_list] == [
"spend:user:target-user",
"spend:committed:user:target-user",
]
@pytest.mark.asyncio
@ -5223,6 +5226,39 @@ async def test_delete_user_evicts_cached_user_rows(mocker: MockerFixture) -> Non
broadcast.assert_awaited_once_with(cache_key=deleted.user_id)
@pytest.mark.asyncio
async def test_delete_user_drops_the_committed_spend_of_the_users_it_deleted(mocker: MockerFixture) -> None:
from litellm.proxy._types import DeleteUserRequest, LiteLLM_UserTable
from litellm.proxy.management_endpoints.internal_user_endpoints import delete_user
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
spend: Final = FakeSpendCounters(
redis=None, memory={"spend:committed:user:user-gone": 7.0, "spend:committed:user:user-stays": 3.0}
)
mocker.patch("litellm.proxy.proxy_server.spend_counter_store", spend.store()) # test-quality-ok: substitute the spend counter dependency
deleted: Final = LiteLLM_UserTable(user_id="user-gone", user_email="gone@example.test", teams=[])
prisma_client: Final = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(return_value=deleted)
prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(return_value=[])
prisma_client.db.litellm_jwtkeymapping.find_many = mocker.AsyncMock(return_value=[])
prisma_client.db.litellm_verificationtoken.find_many = mocker.AsyncMock(return_value=[])
prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1)
_serve_locked_user_delete(prisma_client, deleted)
mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency
mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", None) # test-quality-ok: delete_user reads it off proxy_server at call time
await delete_user(
data=DeleteUserRequest(user_ids=[deleted.user_id]),
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert spend.memory == {"spend:committed:user:user-stays": 3.0}
_DB_OUTAGE_503_BODY: Final = {
"error": {
"message": "Service Unavailable, the authentication database is temporarily unreachable. Please retry shortly.",

View file

@ -8693,6 +8693,7 @@ async def test_reset_key_spend_success(monkeypatch):
mock_spend_counter_cache.in_memory_cache.set_cache.assert_any_call(
key=f"spend_db_floor:spend:key:{hashed_key}", value=50.0, ttl=5
)
assert spend.deleted() == {f"spend:committed:key:{hashed_key}"}
@pytest.mark.asyncio
@ -9013,7 +9014,10 @@ async def test_update_key_spend_updates_counter(monkeypatch):
mock_delete_cache.assert_awaited_once()
assert spend.memory == {f"spend:key:{hashed_key}": 0.0}
assert spend.commands == [f"SET spend:key:{hashed_key} 0.0 60"]
assert spend.commands == [
f"SET spend:key:{hashed_key} 0.0 60",
f"DEL spend:committed:key:{hashed_key}",
]
@pytest.mark.asyncio

View file

@ -1188,6 +1188,27 @@ async def _run_legacy_update_organization(
return mock_prisma_client
@pytest.mark.asyncio
@pytest.mark.parametrize(
("body", "twin_after"),
[
({"organization_id": "org-1", "spend": 0.0}, None),
({"organization_id": "org-1", "organization_alias": "renamed"}, 7.0),
],
)
async def test_legacy_update_drops_the_committed_org_spend_only_when_it_writes_the_stored_spend(
monkeypatch, body, twin_after
):
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
spend: Final = FakeSpendCounters(redis=None, memory={"spend:committed:org:org-1": 7.0})
monkeypatch.setattr("litellm.proxy.proxy_server.spend_counter_store", spend.store())
await _run_legacy_update_organization(monkeypatch, body=body, existing_budget_id="budget-1")
assert spend.memory.get("spend:committed:org:org-1") == twin_after
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body",
@ -1582,6 +1603,49 @@ async def test_delete_organization_evicts_the_cache_of_the_keys_it_deletes(monke
assert jwt_table.rows == (kept_row,)
@pytest.mark.asyncio
async def test_delete_organization_drops_the_committed_spend_of_the_org_and_its_teams(monkeypatch):
from litellm.proxy._types import DeleteOrganizationRequest, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.organization_endpoints import delete_organization
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
spend: Final = FakeSpendCounters(
redis=None,
memory={
"spend:committed:org:org-doomed": 7.0,
"spend:committed:team:team-in-org": 5.0,
"spend:committed:org:org-kept": 3.0,
"spend:committed:team:team-elsewhere": 2.0,
},
)
monkeypatch.setattr("litellm.proxy.proxy_server.spend_counter_store", spend.store())
prisma_client: Final = AsyncMock()
prisma_client.db.litellm_teamtable.find_many = AsyncMock(
return_value=[SimpleNamespace(team_id="team-in-org", organization_id="org-doomed")]
)
prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
prisma_client.db.litellm_jwtkeymapping.find_many = AsyncMock(return_value=[])
prisma_client.db.litellm_organizationtable.delete = AsyncMock(return_value=MagicMock())
@asynccontextmanager
async def transaction():
yield prisma_client.db
prisma_client.tx = transaction
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True, raising=False)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", UserApiKeyCache())
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", None)
await delete_organization(
data=DeleteOrganizationRequest(organization_ids=["org-doomed"]),
user_api_key_dict=UserAPIKeyAuth(api_key="sk-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert spend.memory == {"spend:committed:org:org-kept": 3.0, "spend:committed:team:team-elsewhere": 2.0}
@pytest.mark.asyncio
async def test_delete_organization_is_refused_while_one_of_its_teams_holds_a_ptu_share(monkeypatch):
"""Deleting an organization deletes its teams, so a team still holding a PTU share stops it the

View file

@ -9966,6 +9966,37 @@ async def test_delete_validated_team_rows_deletes_only_the_keys_its_rostered_mem
assert deleted_keys.kwargs["where"] == {"token": {"in": ["hashed-member"]}}
@pytest.mark.asyncio
async def test_delete_validated_team_rows_drops_the_committed_spend_of_the_teams_it_deleted(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
spend: Final = FakeSpendCounters(
redis=None, memory={"spend:committed:team:team-doomed": 7.0, "spend:committed:team:team-kept": 3.0}
)
monkeypatch.setattr("litellm.proxy.proxy_server.spend_counter_store", spend.store())
team = LiteLLM_TeamTable(team_id="team-doomed", members_with_roles=[], metadata={}, model_max_budget={}, model_spend={})
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock()
mock_access_group_tx_cm = MagicMock()
mock_access_group_tx_cm.__aenter__ = AsyncMock(return_value=AsyncMock())
mock_access_group_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_access_group_tx_cm)
_wire_team_delete_tx(mock_prisma_client, locked_teams=(team,), team_keys=())
monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
await delete_validated_team_rows(
teams=(team,),
prisma_client=mock_prisma_client,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="hashed-admin"),
litellm_changed_by=None,
)
assert spend.memory == {"spend:committed:team:team-kept": 3.0}
@pytest.mark.asyncio
async def test_delete_validated_teams_writes_the_deleted_audit_row_only_for_a_team_still_there_under_the_lock(
monkeypatch: pytest.MonkeyPatch,

View file

@ -2,11 +2,12 @@ import asyncio
import copy
import itertools
import json
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import Final, cast
import pytest
from pytest_mock import MockerFixture
from pydantic import BaseModel, ConfigDict, ValidationError
import litellm
@ -22,6 +23,7 @@ from litellm.proxy.management_helpers.bulk_user_deletion import (
)
from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkDeleteUserRequest
from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest, TeamMemberRef
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin")
INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER)
@ -697,6 +699,34 @@ async def test_bulk_delete_evicts_jwt_key_mappings_of_the_deleted_users_keys():
assert cache.get_cache(key=kept) is not None
async def _delete_through_scim_or_user_delete(prisma: _FakePrisma, user_ids: Sequence[str]) -> object:
return await delete_users_from_current_teams(
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
user_ids=user_ids,
user_api_key_dict=ADMIN,
user_api_key_cache=UserApiKeyCache(),
proxy_logging_obj=None,
litellm_changed_by=None,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("delete", [_delete, _delete_through_scim_or_user_delete])
async def test_user_deletes_drop_the_committed_spend_of_the_users_they_deleted(
delete: Callable[[_FakePrisma, Sequence[str]], Awaitable[object]], mocker: MockerFixture
) -> None:
spend: Final = FakeSpendCounters(
redis=None, memory={"spend:committed:user:u1": 7.0, "spend:committed:user:keep": 3.0}
)
mocker.patch("litellm.proxy.proxy_server.spend_counter_store", spend.store()) # test-quality-ok: substitute the spend counter dependency
prisma: Final = _FakePrisma(users=[_user("u1"), _user("keep")])
_ = await delete(prisma, ["u1"])
assert sorted(prisma.db.litellm_usertable.rows) == ["keep"]
assert spend.memory == {"spend:committed:user:keep": 3.0}
@pytest.mark.asyncio
async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db():
prisma = _FakePrisma(users=[_user("u1")])

View file

@ -44,6 +44,8 @@ from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitErro
from litellm.proxy.hooks.parallel_request_limiter import PROXY_MaxParallelRequestsHandler
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.types.proxy.auth.user_api_key_auth import COMMITTED_BUDGET_SPEND_PARAM
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import router as llm_passthrough_router
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import (
VertexPassthroughLoggingHandler,
@ -9693,6 +9695,31 @@ async def _track_cost_for_passthrough_kwargs(kwargs: dict) -> AsyncMock:
return increment_spend_counters
@pytest.mark.asyncio
async def test_passthrough_charge_moves_the_committed_spend_of_the_entities_auth_named_budgeted():
"""A forged body value stays out, and loggers that dump the row never get the auth object or the spend it read."""
counter_keys: Final = EntitySpendCounterKeys.of(
token="hashed-token", team_id="t1", user_id="u1", org_id=None, end_user_id=None
)
user_api_key_dict: Final = UserAPIKeyAuth(
api_key="hashed-token",
user_id="u1",
committed_budget_spend=CommittedBudgetSpend(counter_keys=counter_keys, key=0.123456),
metadata={"logging": [{"callback_name": "langfuse"}], "kept": "yes"},
)
kwargs: Final = _passthrough_kwargs_for_reservation(
user_api_key_dict,
parsed_body={"litellm_metadata": {"user_api_key_auth": {"committed_budget_spend": None}}},
)
increment_spend_counters: Final = await _track_cost_for_passthrough_kwargs(kwargs)
assert increment_spend_counters.await_args.kwargs["committed_spend_scope"] == counter_keys
assert "user_api_key_auth" not in kwargs["litellm_params"]["metadata"]
assert "committed_budget_spend" not in repr(kwargs["litellm_params"]["metadata"])
assert "0.123456" not in json.dumps(kwargs, default=str)
@pytest.mark.asyncio
async def test_passthrough_success_reconciles_budget_reservation():
"""
@ -11135,6 +11162,9 @@ def _split_pass_through_body(
GEMINI_BODY: Final = '{"contents": [{"parts": [{"text": "hi"}]}], "generationConfig": {"temperature": 0}}'
PROXY_SET_PARAMS: Final = ("metadata", "proxy_server_request", COMMITTED_BUDGET_SPEND_PARAM)
def _metadata_of(split: _PassThroughSplit) -> Mapping[str, object]:
return MappingProxyType(_LITELLM_PARAMS.validate_python(split.litellm_params["metadata"]))
@ -11146,7 +11176,7 @@ def test_passthrough_moves_every_litellm_owned_key_from_the_forwarded_body_into_
)
assert frozenset(split.litellm_params) == frozenset(
("ttl", "num_retries", "litellm_trace_id", "metadata", "proxy_server_request")
("ttl", "num_retries", "litellm_trace_id", *PROXY_SET_PARAMS)
)
assert tuple(split.litellm_params[k] for k in ("ttl", "num_retries", "litellm_trace_id")) == (30, 2, "trace-a")
assert split.forwarded_body == json.loads(GEMINI_BODY)
@ -11180,7 +11210,7 @@ PROXY_STAMPED_NAMES: Final = frozenset(
def test_passthrough_keeps_each_registered_litellm_owned_name_out_of_the_forwarded_body(name: str) -> None:
split: Final = _split_pass_through_body(json.dumps({name: "owned", **json.loads(GEMINI_BODY)}))
assert frozenset(split.litellm_params) == frozenset((name, "metadata", "proxy_server_request"))
assert frozenset(split.litellm_params) == frozenset((name, *PROXY_SET_PARAMS))
assert split.litellm_params[name] == "owned"
assert split.forwarded_body == json.loads(GEMINI_BODY)
@ -11224,7 +11254,7 @@ def test_passthrough_keeps_a_client_model_info_object_for_keys_that_allow_client
def test_passthrough_drops_a_client_supplied_proxy_stamped_name(name: str) -> None:
split: Final = _split_pass_through_body(json.dumps({name: {"forged": "by-client"}, **json.loads(GEMINI_BODY)}))
assert frozenset(split.litellm_params) == frozenset(("metadata", "proxy_server_request"))
assert frozenset(split.litellm_params) == frozenset(PROXY_SET_PARAMS)
assert split.litellm_params["proxy_server_request"] != {"forged": "by-client"}, split.litellm_params
assert split.forwarded_body == json.loads(GEMINI_BODY)
@ -11235,7 +11265,7 @@ def test_passthrough_merges_both_metadata_carriers_from_the_body_into_one_metada
' "litellm_metadata": {"lm": "b"}, "generationConfig": {"temperature": 0}}'
)
assert frozenset(split.litellm_params) == frozenset(("ttl", "metadata", "proxy_server_request"))
assert frozenset(split.litellm_params) == frozenset(("ttl", *PROXY_SET_PARAMS))
assert _metadata_of(split) == {**_metadata_of(_split_pass_through_body(GEMINI_BODY)), "client_tag": "a", "lm": "b"}
assert split.forwarded_body == json.loads(GEMINI_BODY)
@ -11274,7 +11304,7 @@ def test_passthrough_sees_a_name_appended_to_the_public_list_after_import() -> N
finally:
litellm.all_litellm_params.remove("registered_later")
assert frozenset(split.litellm_params) == frozenset(("registered_later", "metadata", "proxy_server_request"))
assert frozenset(split.litellm_params) == frozenset(("registered_later", *PROXY_SET_PARAMS))
assert split.forwarded_body == {"contents": [{"parts": [{"text": "hi"}]}]}
@ -11283,7 +11313,7 @@ def test_passthrough_sees_the_public_list_rebound_after_import(monkeypatch: pyte
split: Final = _split_pass_through_body(LATE_REGISTERED_BODY)
assert frozenset(split.litellm_params) == frozenset(("registered_later", "metadata", "proxy_server_request"))
assert frozenset(split.litellm_params) == frozenset(("registered_later", *PROXY_SET_PARAMS))
assert split.forwarded_body == {"contents": [{"parts": [{"text": "hi"}]}]}

View file

@ -38,10 +38,13 @@ from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.models.tag import LiteLLM_TagTable
from litellm.models.team import LiteLLM_TeamTable, LiteLLM_TeamTableCachedObj
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_local
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache, end_user_cache_key, tag_cache_key
from litellm.proxy.management_endpoints.team_endpoints import refresh_cached_team
from litellm.proxy.spend_tracking.committed_spend import committed_spend_key
from litellm.types.proxy.carried_budget_state import EntitySpendCounterKeys
from .conftest import normalize
from litellm.rust_bridge._native import NativeRedisStoreError
@ -679,7 +682,7 @@ async def test_increment_spend_counters_pipelines_all_scopes_in_one_redis_call(
"spend:org:org-1",
}
spend = _spend_counters(monkeypatch, redis=dict.fromkeys(expected_keys, 1.0))
monkeypatch.setattr(ps, "user_api_key_cache", ps.DualCache())
monkeypatch.setattr(ps, "user_api_key_cache", UserApiKeyCache())
monkeypatch.setattr(ps, "prisma_client", None)
await ps.increment_spend_counters(
@ -697,6 +700,97 @@ async def test_increment_spend_counters_pipelines_all_scopes_in_one_redis_call(
assert spend.memory == dict.fromkeys(expected_keys, 1.5)
class _VerificationTokenTable:
def __init__(self, spend: float) -> None:
self._spend: Final = spend
self.lookups: list[dict[str, str]] = []
async def find_unique(self, where: dict[str, str]) -> MagicMock:
self.lookups.append(where)
return MagicMock(token=where["token"], spend=self._spend)
def _prisma_with_key_spend(spend: float) -> MagicMock:
prisma: Final = MagicMock()
prisma.db.litellm_verificationtoken = _VerificationTokenTable(spend)
return prisma
@pytest.mark.asyncio
async def test_committed_twin_takes_the_charge_but_never_a_budget_hold(monkeypatch):
"""The key counter holds the DB spend 10.0, this request's 2.0 reservation and another request's 1.0 hold."""
spend: Final = _spend_counters(monkeypatch, redis=None, memory={"spend:key:hashed": 13.0})
monkeypatch.setattr(ps, "user_api_key_cache", UserApiKeyCache())
monkeypatch.setattr(ps, "prisma_client", _prisma_with_key_spend(10.0))
reservation: Final = {"reserved_cost": 2.0, "entries": [{"counter_key": "spend:key:hashed"}], "finalized": False}
scope: Final = EntitySpendCounterKeys(key="spend:key:hashed")
await ps.increment_spend_counters(
token="hashed",
team_id=None,
user_id=None,
response_cost=0.5,
budget_reservation=reservation,
committed_spend_scope=scope,
)
await ps.increment_spend_counters(
token="hashed", team_id=None, user_id=None, response_cost=0.25, committed_spend_scope=scope
)
assert spend.memory == pytest.approx({"spend:key:hashed": 11.75, committed_spend_key("spend:key:hashed"): 10.75})
@pytest.mark.asyncio
async def test_a_charge_outside_the_committed_scope_keeps_no_twin_and_reads_no_stored_spend(monkeypatch):
spend: Final = _spend_counters(monkeypatch, redis=None, memory={"spend:key:hashed": 10.0})
prisma: Final = _prisma_with_key_spend(10.0)
key_cache: Final = UserApiKeyCache()
key_cache.set_cache(key="hashed", value=UserAPIKeyAuth(token="hashed"))
monkeypatch.setattr(ps, "user_api_key_cache", key_cache)
monkeypatch.setattr(ps, "prisma_client", prisma)
await ps.increment_spend_counters(token="hashed", team_id=None, user_id=None, response_cost=0.5)
assert spend.memory == pytest.approx({"spend:key:hashed": 10.5})
assert prisma.db.litellm_verificationtoken.lookups == []
@pytest.mark.asyncio
async def test_only_the_entities_in_the_committed_scope_take_the_charge_on_their_twin(monkeypatch):
spend: Final = _spend_counters(monkeypatch, redis=None, memory={"spend:key:hashed": 10.0, "spend:team:t1": 10.0})
monkeypatch.setattr(ps, "user_api_key_cache", UserApiKeyCache())
monkeypatch.setattr(ps, "prisma_client", _prisma_with_key_spend(10.0))
await ps.increment_spend_counters(
token="hashed",
team_id="t1",
user_id=None,
response_cost=0.5,
committed_spend_scope=EntitySpendCounterKeys(key="spend:key:hashed"),
)
assert spend.memory.get(committed_spend_key("spend:key:hashed")) == pytest.approx(10.5)
assert committed_spend_key("spend:team:t1") not in spend.memory
@pytest.mark.asyncio
async def test_a_budgeted_team_whose_id_names_a_window_keeps_its_charge_and_gets_no_twin(monkeypatch):
team_counter_key: Final = "spend:team:t1:window:1d"
spend: Final = _spend_counters(monkeypatch, redis=None, memory={team_counter_key: 1.0})
monkeypatch.setattr(ps, "user_api_key_cache", UserApiKeyCache())
monkeypatch.setattr(ps, "prisma_client", MagicMock())
await ps.increment_spend_counters(
token=None,
team_id="t1:window:1d",
user_id=None,
response_cost=0.5,
committed_spend_scope=EntitySpendCounterKeys(team=team_counter_key),
)
assert spend.memory == pytest.approx({team_counter_key: 1.5})
_PENDING_SPEND_COUNTER_KEYS: tuple[str, ...] = (
"spend:key:hashed",
"spend:team:team-1",
@ -717,7 +811,7 @@ def _warm_counters_behind_a_failing_pipeline(monkeypatch, failure: str) -> FakeS
fail_pipelines=True,
failure=failure,
)
monkeypatch.setattr(ps, "user_api_key_cache", ps.DualCache())
monkeypatch.setattr(ps, "user_api_key_cache", UserApiKeyCache())
monkeypatch.setattr(ps, "prisma_client", None)
return spend

View file

@ -2,6 +2,9 @@
from datetime import datetime, timezone
import pytest
from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
@ -11,10 +14,13 @@ from litellm.proxy._types import (
)
from litellm.proxy.spend_tracking.carried_budget_state import (
carried_budget_metadata,
carry_committed_budget_spend,
carry_organization_budget_state,
carry_team_and_user_budget_state,
)
from litellm.types.proxy.carried_budget_state import (
CommittedBudgetSpend,
EntitySpendCounterKeys,
KeyBudgetSnapshot,
OrgBudgetSnapshot,
TeamBudgetSnapshot,
@ -123,10 +129,105 @@ def test_snapshots_never_reach_the_serialized_token():
user_object=LiteLLM_UserTable(user_id="u1", user_alias="Alice"),
)
token.org_budget_snapshot = OrgBudgetSnapshot(spend=1.0, max_budget=2.0)
metadata_without_committed_spend = dict(carried_budget_metadata(token))
token.committed_budget_spend = CommittedBudgetSpend(key=1.0, team=2.0, user=3.0, org=4.0, end_user=5.0)
dumped = token.model_dump()
assert "team_budget_snapshot" not in dumped
assert "user_budget_snapshot" not in dumped
assert "org_budget_snapshot" not in dumped
assert "committed_budget_spend" not in dumped
assert dict(carried_budget_metadata(token)) == metadata_without_committed_spend
assert UserAPIKeyAuth(**dumped).team_budget_snapshot is None
def _committed_reader(committed: CommittedBudgetSpend):
reads: list[EntitySpendCounterKeys] = []
async def read_committed(counter_keys: EntitySpendCounterKeys) -> CommittedBudgetSpend:
reads.append(counter_keys)
return committed
return read_committed, reads
@pytest.mark.asyncio
async def test_committed_spend_is_read_once_for_every_budgeted_entity():
token = UserAPIKeyAuth(token="hashed", team_id="t1", user_id="u1", org_id="o1", spend=1.0, max_budget=10.0)
token.org_budget_snapshot = OrgBudgetSnapshot(spend=40.0, max_budget=500.0)
committed = CommittedBudgetSpend(key=1.3, team=20.3, user=5.3, org=40.3, end_user=0.8)
read_committed, reads = _committed_reader(committed)
await carry_committed_budget_spend(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", max_budget=300.0, spend=20.0),
user_object=LiteLLM_UserTable(user_id="u1", max_budget=50.0, spend=5.0),
end_user_object=LiteLLM_EndUserTable(
user_id="cust-1", blocked=False, spend=0.5, litellm_budget_table=LiteLLM_BudgetTable(max_budget=3.0)
),
read_committed=read_committed,
)
assert reads == [
EntitySpendCounterKeys(
key="spend:key:hashed",
team="spend:team:t1",
user="spend:user:u1",
org="spend:org:o1",
end_user="spend:end_user:cust-1",
)
]
assert token.committed_budget_spend == committed
assert (token.spend, token.team_spend) == (1.0, None)
@pytest.mark.asyncio
async def test_route_without_a_budget_check_carries_the_counters_it_charges_and_reads_nothing():
token = UserAPIKeyAuth(token="hashed", max_budget=10.0)
await carry_committed_budget_spend(
valid_token=token, team_object=None, user_object=None, end_user_object=None, read_committed=None
)
assert token.committed_budget_spend == CommittedBudgetSpend(
counter_keys=EntitySpendCounterKeys(key="spend:key:hashed")
)
@pytest.mark.asyncio
async def test_committed_spend_skips_entities_without_a_max_budget():
token = UserAPIKeyAuth(token="hashed", team_id="t1", user_id="u1", org_id="o1", spend=1.0)
token.org_budget_snapshot = OrgBudgetSnapshot(spend=40.0, max_budget=None)
read_committed, reads = _committed_reader(CommittedBudgetSpend())
await carry_committed_budget_spend(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", spend=20.0),
user_object=LiteLLM_UserTable(user_id="u1", spend=5.0),
end_user_object=LiteLLM_EndUserTable(
user_id="cust-1", blocked=False, spend=0.5, litellm_budget_table=LiteLLM_BudgetTable(max_budget=None)
),
read_committed=read_committed,
)
assert reads == [EntitySpendCounterKeys()]
assert token.committed_budget_spend == CommittedBudgetSpend()
@pytest.mark.asyncio
async def test_committed_spend_reads_the_team_org_when_the_key_has_none():
token = UserAPIKeyAuth(token="hashed", team_id="t1")
token.org_budget_snapshot = OrgBudgetSnapshot(spend=40.0, max_budget=500.0)
read_committed, reads = _committed_reader(CommittedBudgetSpend(org=41.0))
await carry_committed_budget_spend(
valid_token=token,
team_object=LiteLLM_TeamTable(team_id="t1", organization_id="team-org"),
user_object=None,
end_user_object=None,
read_committed=read_committed,
)
assert reads == [EntitySpendCounterKeys(org="spend:org:team-org")]
assert token.committed_budget_spend == CommittedBudgetSpend(org=41.0)

View file

@ -0,0 +1,274 @@
"""Committed spend twins hold only charged cost, so the remaining budget gauges never count a budget hold."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Mapping
from types import MappingProxyType, SimpleNamespace
from typing import Final
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
import litellm
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy.spend_tracking.committed_spend import (
bind_committed_admission_counters,
drop_committed_spend,
read_admission_committed_spend,
read_committed_budget_spend,
seed_committed_spend,
)
from litellm.proxy.spend_tracking.spend_counter_batch import (
active_spend_counter_batch,
read_batched_spend_counter,
spend_counter_batch_scope,
)
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.types.proxy.carried_budget_state import CommittedBudgetSpend, EntitySpendCounterKeys
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
def _no_side_effect() -> None:
return None
class _FindUniqueTable:
def __init__(self, row: SimpleNamespace | None, on_read: Callable[[], None]) -> None:
self._row: Final = row
self._on_read: Final = on_read
self.reads = 0
async def find_unique(self, where: dict[str, str]) -> SimpleNamespace | None:
self.reads += 1
await asyncio.sleep(0)
self._on_read()
return self._row
def _prisma_with_spend(spend: float | None, on_read: Callable[[], None] = _no_side_effect) -> SimpleNamespace:
row: Final = None if spend is None else SimpleNamespace(spend=spend)
return SimpleNamespace(
db=SimpleNamespace(
litellm_verificationtoken=_FindUniqueTable(row, on_read),
litellm_teamtable=_FindUniqueTable(row, on_read),
litellm_endusertable=_FindUniqueTable(row, on_read),
)
)
@pytest.fixture(params=[False, True], ids=["in_memory", "redis"])
def has_redis(request: pytest.FixtureRequest) -> bool:
return request.param
def _spend_counters(has_redis: bool, counters: Mapping[str, float] = MappingProxyType({})) -> FakeSpendCounters:
if has_redis:
return FakeSpendCounters(redis=dict(counters))
return FakeSpendCounters(redis=None, memory=dict(counters))
@pytest.mark.asyncio
async def test_read_returns_each_committed_twin_and_none_for_one_never_charged(has_redis: bool):
spend: Final = _spend_counters(
has_redis,
{"spend:committed:key:hashed": 1.5, "spend:committed:end_user:cust-1": 0.25, "spend:team:t1": 99.0},
)
counter_keys: Final = EntitySpendCounterKeys.of(
token="hashed", team_id="t1", user_id=None, org_id=None, end_user_id="cust-1"
)
committed: Final = await read_committed_budget_spend(spend.store(), counter_keys)
assert committed == CommittedBudgetSpend(counter_keys=counter_keys, key=1.5, end_user=0.25)
@pytest.mark.asyncio
async def test_a_twin_holding_garbage_reads_as_unknown_without_hiding_the_other_twins():
spend: Final = FakeSpendCounters(
redis={"spend:committed:key:hashed": "not-a-number", "spend:committed:team:t1": 4.0}
)
counter_keys: Final = EntitySpendCounterKeys.of(
token="hashed", team_id="t1", user_id=None, org_id=None, end_user_id=None
)
committed: Final = await read_committed_budget_spend(spend.store(), counter_keys)
assert committed == CommittedBudgetSpend(counter_keys=counter_keys, team=4.0)
@pytest.mark.parametrize(
("counter_keys", "expected_spend"),
[
(EntitySpendCounterKeys(key="spend:key:hashed"), {"key": 12.0}),
(EntitySpendCounterKeys(end_user="spend:end_user:cust-1"), {"end_user": 12.0}),
],
)
@pytest.mark.asyncio
async def test_seed_writes_the_db_spend_when_the_twin_is_absent(
has_redis: bool, counter_keys: EntitySpendCounterKeys, expected_spend: dict[str, float]
):
store: Final = _spend_counters(has_redis).store()
(counter_key,) = counter_keys.tracked()
seeded: Final = await seed_committed_spend(store, _prisma_with_spend(12.0), counter_key)
assert seeded is True
assert await read_committed_budget_spend(store, counter_keys) == CommittedBudgetSpend(
counter_keys=counter_keys, **expected_spend
)
@pytest.mark.asyncio
async def test_concurrent_charges_on_a_cold_twin_read_the_db_once(has_redis: bool):
store: Final = _spend_counters(has_redis).store()
prisma_client: Final = _prisma_with_spend(12.0)
counter_key: Final = f"spend:key:{uuid4()}"
seeded: Final = await asyncio.gather(*(seed_committed_spend(store, prisma_client, counter_key) for _ in range(5)))
assert seeded == [True] * 5
assert prisma_client.db.litellm_verificationtoken.reads == 1
@pytest.mark.asyncio
async def test_seed_reports_no_twin_when_the_twin_cannot_be_read():
async def unreachable(counter: str) -> None:
raise ConnectionError("redis unreachable")
spend: Final = FakeSpendCounters(read_hook=unreachable)
assert await seed_committed_spend(spend.store(), _prisma_with_spend(12.0), "spend:team:t1") is False
@pytest.mark.asyncio
async def test_seed_never_lowers_a_twin_another_replica_already_advanced(has_redis: bool):
store: Final = _spend_counters(has_redis, {"spend:committed:team:t1": 20.0}).store()
seeded: Final = await seed_committed_spend(store, _prisma_with_spend(12.0), "spend:team:t1")
committed: Final = await read_committed_budget_spend(store, EntitySpendCounterKeys(team="spend:team:t1"))
assert seeded is True
assert committed.team == 20.0
@pytest.mark.asyncio
async def test_seed_keeps_the_twin_another_worker_wrote_while_this_one_read_the_db():
spend: Final = FakeSpendCounters(redis={})
def another_worker_seeds_and_charges() -> None:
assert spend.redis is not None
spend.redis["spend:committed:team:t1"] = 20.0
seeded: Final = await seed_committed_spend(
spend.store(), _prisma_with_spend(12.0, on_read=another_worker_seeds_and_charges), "spend:team:t1"
)
committed: Final = await read_committed_budget_spend(spend.store(), EntitySpendCounterKeys(team="spend:team:t1"))
assert seeded is True
assert committed.team == 20.0
@pytest.mark.asyncio
async def test_read_without_any_counter_key_sends_redis_nothing():
spend: Final = FakeSpendCounters(redis={})
committed: Final = await read_committed_budget_spend(spend.store(), EntitySpendCounterKeys())
assert committed == CommittedBudgetSpend()
assert spend.commands == []
@pytest.mark.asyncio
async def test_seed_reports_no_twin_when_redis_rejects_the_write():
spend: Final = FakeSpendCounters(redis={}, fail=True)
seeded: Final = await seed_committed_spend(spend.store(), _prisma_with_spend(12.0), "spend:team:t1")
committed: Final = await read_committed_budget_spend(spend.store(), EntitySpendCounterKeys(team="spend:team:t1"))
assert seeded is False
assert committed.team is None
@pytest.mark.asyncio
async def test_seed_creates_no_twin_when_the_db_has_no_spend_for_the_entity(has_redis: bool):
store: Final = _spend_counters(has_redis).store()
seeded: Final = await seed_committed_spend(store, _prisma_with_spend(None), "spend:key:hashed")
committed: Final = await read_committed_budget_spend(store, EntitySpendCounterKeys(key="spend:key:hashed"))
assert seeded is False
assert committed.key is None
@pytest.mark.asyncio
async def test_admission_read_rides_the_admission_mget_with_the_enforcement_counters(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setattr(litellm, "callbacks", [MagicMock(spec=PrometheusLogger)])
spend: Final = FakeSpendCounters(redis={"spend:committed:key:hashed": 1.5})
store: Final = spend.store()
token: Final = UserAPIKeyAuth(token="hashed", team_id="t1")
counter_keys: Final = EntitySpendCounterKeys(key="spend:key:hashed", team="spend:team:t1")
with spend_counter_batch_scope(store, counter_keys=frozenset(("spend:key:hashed",))):
bind_committed_admission_counters(token, end_user_id=None)
await read_batched_spend_counter("spend:key:hashed")
committed: Final = await read_admission_committed_spend(store, counter_keys)
assert committed == CommittedBudgetSpend(counter_keys=counter_keys, key=1.5)
assert len(spend.commands) == 1
assert {"spend:committed:key:hashed", "spend:committed:team:t1"} <= set(spend.commands[0].split()[1:])
@pytest.mark.asyncio
async def test_admission_read_outside_a_batch_leaves_redis_alone_and_the_spend_unknown():
spend: Final = FakeSpendCounters(redis={"spend:committed:key:hashed": 1.5})
counter_keys: Final = EntitySpendCounterKeys(key="spend:key:hashed")
committed: Final = await read_admission_committed_spend(spend.store(), counter_keys)
assert committed == CommittedBudgetSpend(counter_keys=counter_keys)
assert spend.commands == []
@pytest.mark.asyncio
async def test_admission_read_without_redis_reads_the_in_memory_twin():
spend: Final = FakeSpendCounters(redis=None, memory={"spend:committed:key:hashed": 1.5})
committed: Final = await read_admission_committed_spend(
spend.store(), EntitySpendCounterKeys(key="spend:key:hashed")
)
assert committed.key == 1.5
def test_no_committed_counter_is_bound_without_a_prometheus_logger():
with spend_counter_batch_scope(FakeSpendCounters(redis={}).store()):
bind_committed_admission_counters(UserAPIKeyAuth(token="hashed"), end_user_id=None)
batch: Final = active_spend_counter_batch()
assert batch is not None
bound: Final = batch.counter_keys
assert bound == frozenset()
@pytest.mark.asyncio
async def test_drop_removes_the_twin_and_leaves_the_enforcement_counter(has_redis: bool):
store: Final = _spend_counters(has_redis, {"spend:committed:org:o1": 5.0, "spend:org:o1": 5.0}).store()
counter_keys: Final = EntitySpendCounterKeys(org="spend:org:o1")
await drop_committed_spend(store, ("spend:org:o1",))
assert (await read_committed_budget_spend(store, counter_keys)).org is None
assert (await store.read("spend:org:o1"))[0] == 5.0
@pytest.mark.asyncio
async def test_a_failed_drop_never_fails_the_write_that_already_committed():
spend: Final = FakeSpendCounters(redis={"spend:committed:org:o1": 5.0}, failing=frozenset({"delete"}))
await drop_committed_spend(spend.store(), ("spend:org:o1",))
assert spend.deleted() == frozenset({"spend:committed:org:o1"})

View file

@ -1,12 +1,14 @@
import asyncio
import collections
import contextlib
import datetime
import hashlib
import json
import re
import sqlite3
from collections.abc import Mapping, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from datetime import timezone
from types import SimpleNamespace
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, patch
@ -17,14 +19,17 @@ from fastapi.testclient import TestClient
import litellm
import litellm.proxy.proxy_server as ps
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy.auth.authorization import OwnedRows
from litellm.proxy.auth.authorization_dependencies import get_log_team_lookup, load_permitted_log_team_ids
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.spend_tracking.spend_management_endpoints import global_spend_reset
from tests.unit.proxy.db.fake_prisma_engine import (
ReadTargetOf,
statement_timeout_error,
statement_timeout_reads,
)
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
def _default_date_range():
@ -8336,6 +8341,109 @@ async def test_global_spend_refresh_runs_the_refresh_on_the_writer_when_a_reader
]
class _ResetTableRows:
def __init__(self) -> None:
self.update_many: Final = AsyncMock()
class _ResetDb:
def __init__(self) -> None:
self.litellm_verificationtoken: Final = _ResetTableRows()
self.litellm_teamtable: Final = _ResetTableRows()
async def query_raw(self, sql_query: str, after_id: str, limit: int) -> list[dict[str, str]]:
rows: Final = ["hashed"] if "VerificationToken" in sql_query else ["t1"]
return [{"id": row} for row in rows if row > after_id][:limit]
class _ResetTransaction:
def __init__(self, db: _ResetDb) -> None:
self.litellm_verificationtoken: Final = db.litellm_verificationtoken
self.litellm_teamtable: Final = db.litellm_teamtable
async def query_raw(self, sql_query: str) -> list[dict[str, str]]:
return []
def _reset_prisma(db: _ResetDb) -> SimpleNamespace:
@contextlib.asynccontextmanager
async def tx() -> AsyncIterator[_ResetTransaction]:
yield _ResetTransaction(db)
return SimpleNamespace(db=db, tx=tx)
def _committed_spend(monkeypatch: pytest.MonkeyPatch, twins: Sequence[str]) -> FakeSpendCounters:
spend: Final = FakeSpendCounters(redis=None, memory=dict.fromkeys(twins, 7.0))
monkeypatch.setattr("litellm.proxy.proxy_server.spend_counter_store", spend.store())
return spend
@pytest.mark.asyncio
@pytest.mark.parametrize(
("registered_callbacks", "twin_after"),
[
([MagicMock(spec=PrometheusLogger)], None),
([], 7.0),
],
)
async def test_global_spend_reset_drops_the_committed_key_and_team_spend_the_gauges_read(
monkeypatch, registered_callbacks, twin_after
):
twins: Final = ("spend:committed:key:hashed", "spend:committed:team:t1")
spend: Final = _committed_spend(monkeypatch, twins)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _reset_prisma(_ResetDb()))
monkeypatch.setattr(litellm, "callbacks", registered_callbacks)
response: Final = await global_spend_reset()
assert response["status"] == "success"
assert [spend.memory.get(twin) for twin in twins] == [twin_after, twin_after]
class _PagedResetDb(_ResetDb):
def __init__(self, token_count: int) -> None:
super().__init__()
self._tokens: Final = sorted(f"token-{index}" for index in range(token_count))
self.largest_page = 0
async def query_raw(self, sql_query: str, after_id: str, limit: int) -> list[dict[str, str]]:
rows: Final = self._tokens if "VerificationToken" in sql_query else []
page: Final = [{"id": row} for row in rows if row > after_id][:limit]
self.largest_page = max(self.largest_page, len(page))
return page
@pytest.mark.asyncio
@pytest.mark.parametrize("token_count", [999, 1000, 1001, 2500])
async def test_global_spend_reset_drops_every_key_twin_reading_the_keys_in_bounded_pages(monkeypatch, token_count):
db: Final = _PagedResetDb(token_count)
twins: Final = tuple(f"spend:committed:key:token-{index}" for index in range(token_count))
spend: Final = _committed_spend(monkeypatch, twins)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _reset_prisma(db))
monkeypatch.setattr(litellm, "callbacks", [MagicMock(spec=PrometheusLogger)])
response: Final = await global_spend_reset()
assert response["status"] == "success"
assert spend.memory == {}
assert db.largest_page <= 1000
@pytest.mark.asyncio
async def test_global_spend_reset_still_succeeds_when_listing_the_committed_counters_fails(monkeypatch):
db: Final = _ResetDb()
db.query_raw = AsyncMock(side_effect=ConnectionError("db unreachable"))
_committed_spend(monkeypatch, ("spend:committed:team:t1",))
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _reset_prisma(db))
monkeypatch.setattr(litellm, "callbacks", [MagicMock(spec=PrometheusLogger)])
response: Final = await global_spend_reset()
assert response["status"] == "success"
db.litellm_teamtable.update_many.assert_awaited_once()
def test_spend_calculate_rejection_body_is_openai_shaped(client):
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user", api_key="hashed-k"

View file

@ -918,6 +918,49 @@ async def test_initialize_scheduled_jobs_loads_credentials_only_through_add_depl
scheduler.shutdown(wait=False)
@pytest.mark.asyncio
@pytest.mark.parametrize("proxy_batch_write_at", [10, 60])
async def test_committed_spend_twins_outlive_the_slowest_spend_write_to_the_db(
monkeypatch: pytest.MonkeyPatch, proxy_batch_write_at: int
) -> None:
"""A twin that expires before its charges reach the DB seeds again from a total still missing them."""
monkeypatch.delenv("DISABLE_PRISMA_SCHEMA_UPDATE", raising=False)
monkeypatch.delenv("STORE_MODEL_IN_DB", raising=False)
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from litellm.constants import SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS
from litellm.proxy.proxy_server import _BATCH_WRITE_JITTER_MAX_SECONDS, ProxyStartupEvent
from litellm.proxy.utils import ProxyLogging
from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters
spend: Final = FakeSpendCounters()
mock_proxy_logging: Final = MagicMock(spec=ProxyLogging)
mock_proxy_logging.slack_alerting_instance = MagicMock()
mock_proxy_logging.db_spend_update_writer = MagicMock()
scheduler: Final = AsyncIOScheduler()
try:
with (
patch("litellm.proxy.proxy_server.proxy_config", _mock_scheduled_proxy_config()),
patch("litellm.proxy.proxy_server.store_model_in_db", False),
patch("litellm.proxy.proxy_server.AsyncIOScheduler", return_value=scheduler),
patch("litellm.proxy.proxy_server.spend_counter_store", spend.store()),
):
await ProxyStartupEvent.initialize_scheduled_background_jobs(
general_settings={},
prisma_client=MagicMock(),
proxy_budget_rescheduler_min_time=1,
proxy_budget_rescheduler_max_time=2,
proxy_batch_write_at=proxy_batch_write_at,
proxy_logging_obj=mock_proxy_logging,
)
finally:
scheduler.shutdown(wait=False)
slowest_db_write: Final = SPEND_LOG_QUEUE_MAX_BACKOFF_SECONDS + proxy_batch_write_at + _BATCH_WRITE_JITTER_MAX_SECONDS
assert spend.committed_ttl is not None
assert spend.committed_ttl >= slowest_db_write
@pytest.mark.asyncio
async def test_periodic_reload_job_scheduled_without_store_model_in_db(monkeypatch):
"""

View file

@ -35,9 +35,22 @@ def _is_number(value: object) -> bool:
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
_TWIN_SCOPES: Final = frozenset({"key", "team", "user", "org", "end_user"})
_WINDOWED_SCOPES: Final = frozenset({"key", "team"})
def _native_store_takes_twin(twin: str) -> bool:
scope, _, counter_id = twin.partition(":")
_, separator, window = counter_id.rpartition(":window:")
return scope in _TWIN_SCOPES and not (scope in _WINDOWED_SCOPES and separator and window)
def _check(counter: str) -> None:
if not counter.startswith("spend:"):
raise ValueError(f"{counter!r} is not a spend counter")
twin: Final = counter.removeprefix("spend:committed:")
if twin != counter and not _native_store_takes_twin(twin):
raise ValueError(f"{counter!r} is not a spend counter")
@dataclass
@ -53,6 +66,11 @@ class DeclaredRead:
return self.owner.mget(self.counters)
@dataclass
class FakeSettlement:
settled: bool = False
@dataclass
class FakeSpendCounters:
"""``redis`` is None for a worker without Redis, and ``redis_default`` answers reads of a key it lacks. ``fail``
@ -68,11 +86,12 @@ class FakeSpendCounters:
failure: str = "transport"
redis_default: float | None = None
redis_ttl: float = 60
committed_ttl: float | None = None
batch_reads: bool = True
read_hook: Callable[[str], Awaitable[None]] | None = None
on_delete: Callable[[tuple[str, ...]], None] | None = None
deletes: list[tuple[str, ...]] = field(default_factory=list)
post_call: list[tuple[object, tuple[SpendIncrement, ...]]] = field(default_factory=list)
post_call: list[tuple[object, tuple[SpendIncrement, ...], FakeSettlement]] = field(default_factory=list)
declared: list[DeclaredRead] = field(default_factory=list)
def store(self) -> SpendCounterStore:
@ -86,6 +105,9 @@ class FakeSpendCounters:
def redis_ttl_seconds(self) -> float | None:
return None if self.redis is None else self.redis_ttl
def set_committed_ttl(self, ttl_seconds: float, /) -> None:
self.committed_ttl = ttl_seconds
def attach(self, client: NativeRedisClient, /) -> bool:
if self.redis is not None:
return False
@ -139,17 +161,19 @@ class FakeSpendCounters:
self.commands.append(f"PIPELINE {' '.join(counter for counter, _ in increments)}")
return _done([self._incr(counter, amount) for counter, amount in increments])
def increment_many_post_call(self, request: object, increments: Sequence[SpendIncrement]) -> bool:
def increment_many_post_call(self, request: object, increments: Sequence[SpendIncrement]) -> FakeSettlement | None:
if self.redis is None:
return False
self.post_call.append((request, tuple(increments)))
return True
return None
settlement: Final = FakeSettlement()
self.post_call.append((request, tuple(increments), settlement))
return settlement
def settle_post_call(self) -> None:
"""What the native task does once the post-call batch goes out."""
pending: Final = tuple(self.post_call)
self.post_call.clear()
for _, increments in pending:
for _, increments, settlement in pending:
settlement.settled = True
if self.fail:
for counter, _ in increments:
self.memory.pop(counter, None)

View file

@ -10,6 +10,7 @@ from litellm.redis.stores.connection import RedisStores, connect
from litellm.redis.stores.spend_counters import (
SPEND_COUNTERS_TARGET,
SpendCounterStore,
committed_counter,
end_user_counter,
is_unavailable,
key_counter,
@ -28,7 +29,7 @@ from litellm.redis.store_settings import RedisStoreSettings
from litellm.rust_bridge._native import NativeRedisClient, NativeRedisStoreError, NativeSpendCounters, SpendRead
from tests.unit.caching.test_redis_batch import FakeNatives, fake_send
from .spend_counters_double import FakeSpendCounters
from .spend_counters_double import FakeSettlement, FakeSpendCounters
_KEYS: Final = (
(
@ -46,6 +47,11 @@ _KEYS: Final = (
(project_counter("proj-1"), "spend:project:proj-1"),
(model_access_group_counter("gpt-group"), "spend:model_access_group:gpt-group"),
(shadow_eval_counter("job-7"), "spend:shadow_eval:job-7"),
(committed_counter(key_counter("88dc28d0")), "spend:committed:key:88dc28d0"),
(committed_counter(team_counter("team-a")), "spend:committed:team:team-a"),
(committed_counter(user_counter("user@example.com")), "spend:committed:user:user@example.com"),
(committed_counter(org_counter("org-1")), "spend:committed:org:org-1"),
(committed_counter(end_user_counter("customer é 1")), "spend:committed:end_user:customer é 1"),
)
@ -81,7 +87,17 @@ def test_a_read_decodes_what_redis_cache_decodes(wire: str, spend: float) -> Non
assert type(decoded) is type(spend)
@pytest.mark.parametrize("key", ["spend_db_floor:spend:key:k", "virtual_key_spend:k:gpt:1d", "spend:unknown:k"])
@pytest.mark.parametrize(
"key",
[
"spend_db_floor:spend:key:k",
"virtual_key_spend:k:gpt:1d",
"spend:unknown:k",
"spend:committed:key:k:window:1d",
"spend:committed:tag:prod",
"spend:committed:committed:key:k",
],
)
def test_the_native_store_refuses_keys_outside_the_spend_counter_keyspace(key: str) -> None:
with pytest.raises(ValueError, match="a spend counter is a spend:\* key"):
NativeSpendCounters(None).get_local(key)
@ -153,7 +169,7 @@ async def test_post_call_increments_go_on_the_requests_native_post_call_batch_un
targets: Final[list[str | None]] = []
class Recording(FakeSpendCounters):
def increment_many_post_call(self, request: object, increments: object) -> bool:
def increment_many_post_call(self, request: object, increments: object) -> FakeSettlement | None:
targets.append(current_service_target())
return super().increment_many_post_call(request, increments) # pyright: ignore[reportArgumentType] # recorded as given
@ -165,8 +181,8 @@ async def test_post_call_increments_go_on_the_requests_native_post_call_batch_un
inside: Final = store.increment_many_post_call((("spend:key:k", 1.0),))
empty: Final = store.increment_many_post_call(())
assert (outside, inside, empty) == (False, True, False)
assert spend.post_call == [(natives.built[0], (("spend:key:k", 1.0),))]
assert (outside, empty) == (None, None)
assert spend.post_call == [(natives.built[0], (("spend:key:k", 1.0),), inside)]
assert targets == [SPEND_COUNTERS_TARGET]