Merge remote-tracking branch 'origin/main' into litellm_fix_mcp_catalog_consistency_4436

This commit is contained in:
Joshua Valluru 2026-09-22 16:47:07 -07:00
commit e5072121d8
81 changed files with 5622 additions and 280 deletions

View file

@ -2956,6 +2956,14 @@ dependencies = [
"url",
]
[[package]]
name = "litellm-cost"
version = "0.1.0"
dependencies = [
"criterion",
"proptest",
]
[[package]]
name = "litellm-framing"
version = "0.1.0"
@ -3046,6 +3054,20 @@ dependencies = [
"url",
]
[[package]]
name = "litellm-model-catalog"
version = "0.1.0"
dependencies = [
"criterion",
"indexmap 2.14.0",
"litellm-model-catalog",
"rstest",
"schemars 1.2.2",
"serde",
"serde_json",
"thiserror 2.0.19",
]
[[package]]
name = "litellm-python-bridge"
version = "0.1.0"
@ -4748,10 +4770,23 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.0",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -4840,6 +4875,17 @@ dependencies = [
"syn 3.0.0",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
]
[[package]]
name = "serde_json"
version = "1.0.150"

View file

@ -0,0 +1,14 @@
[package]
name = "litellm-cost"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dev-dependencies]
criterion.workspace = true
proptest.workspace = true
[[bench]]
name = "calculate"
harness = false

View file

@ -0,0 +1,13 @@
# litellm-cost
This crate calculates text token charges from rates and usage supplied by its caller. It is standalone and has no Python bridge or proxy integration
Call `compile(&pricing)` once for an immutable plan, then `plan.calculate(&request)` for each supported request. `calculate(&pricing, &request)` compiles on each call. A successful result exposes pre-multiplier component costs, selected rates, the multiplier, and derived `input()`, `output()`, and `total()` values
The caller states whether `prompt_tokens` includes cache tokens. Threshold selection uses total input tokens for either convention and selects one rate for the whole request. Thresholds are sorted when compiled, and duplicate thresholds or tier overrides fail deterministically. `Fast` selects priority rates; unknown tiers use standard rates
`Rate::Missing`, `Rate::Null`, and `Rate::Value(0.0)` remain distinct. Missing cache rates fall back to the selected input rate, and an absent one-hour write rate falls back to the selected write rate. Missing input or output rates return typed errors, including for zero usage. Python's sparse-entry behavior remains outside this native contract
The supported off-peak shape is one non-wrapping UTC daily window. The caller supplies the applicable regional multiplier after provider-specific selection. Negative or non-finite rates, ambiguous rules, inconsistent cache counts, incomplete write splits, invalid windows and overflow return errors. Callers must decline unsupported inputs before native execution if their public contract accepts those shapes
This crate does not select models, read catalogs, fetch provider prices, normalize multimodal usage, process provider-reported costs, or calculate non-token charges. It does not change proxy behavior. The reference fixture was generated by `tests/generate_python_reference.py` against the Python implementation at the commit recorded in `tests/python_reference.tsv`, using synthetic rates and fixed usage

View file

@ -0,0 +1,66 @@
use criterion::{Criterion, criterion_group, criterion_main};
use litellm_cost::{
Pricing, PromptConvention, Rate, Rates, Request, ServiceTier, ThresholdPolicy, ThresholdRates,
Usage, calculate, compile,
};
use std::hint::black_box;
fn bench(c: &mut Criterion) {
let pricing = Pricing {
standard: Rates {
input: Rate::Value(0.000002),
output: Rate::Value(0.000008),
cache_read: Rate::Value(0.0000005),
cache_write: Rate::Missing,
cache_write_1h: Rate::Missing,
},
tiers: &[],
thresholds: &[],
off_peak: None,
};
let request = Request {
usage: Usage {
prompt_tokens: 1000,
completion_tokens: 200,
cache_read_tokens: 250,
cache_write_tokens: 0,
cache_write_5m_tokens: None,
cache_write_1h_tokens: None,
prompt_convention: PromptConvention::IncludesCache,
},
service_tier: ServiceTier::Standard,
threshold_policy: ThresholdPolicy::Exclusive,
region_multiplier: None,
billed_at_utc_minute: None,
};
let plan = compile(&pricing).unwrap();
c.bench_function("native_compiled_calculation", |b| {
b.iter(|| black_box(plan.calculate(black_box(&request)).unwrap()))
});
c.bench_function("native_full_wrapper", |b| {
b.iter(|| black_box(calculate(black_box(&pricing), black_box(&request)).unwrap()))
});
c.bench_function("native_rate_compilation", |b| {
b.iter(|| black_box(compile(black_box(&pricing)).unwrap()))
});
let threshold = ThresholdRates {
above_prompt_tokens: 1000,
standard: Rates {
input: Rate::Value(0.000004),
output: Rate::Value(0.000016),
..Rates::EMPTY
},
tiers: &[],
};
let threshold_pricing = Pricing {
thresholds: &[threshold],
..pricing
};
let threshold_plan = compile(&threshold_pricing).unwrap();
c.bench_function("native_threshold_boundary", |b| {
b.iter(|| black_box(threshold_plan.calculate(black_box(&request)).unwrap()))
});
}
criterion_group!(benches, bench);
criterion_main!(benches);

View file

@ -0,0 +1,40 @@
use litellm_cost::{
Pricing, PromptConvention, Rate, Rates, Request, ServiceTier, ThresholdPolicy, Usage, compile,
};
fn main() {
let pricing = Pricing {
standard: Rates {
input: Rate::Value(2.0),
output: Rate::Value(4.0),
cache_read: Rate::Value(0.5),
cache_write: Rate::Value(3.0),
cache_write_1h: Rate::Missing,
},
tiers: &[],
thresholds: &[],
off_peak: None,
};
let request = Request {
usage: Usage {
prompt_tokens: 100,
completion_tokens: 20,
cache_read_tokens: 25,
cache_write_tokens: 10,
cache_write_5m_tokens: None,
cache_write_1h_tokens: None,
prompt_convention: PromptConvention::IncludesCache,
},
service_tier: ServiceTier::Standard,
threshold_policy: ThresholdPolicy::Exclusive,
region_multiplier: None,
billed_at_utc_minute: None,
};
let cost = compile(&pricing).unwrap().calculate(&request).unwrap();
println!(
"input={} output={} total={}",
cost.input(),
cost.output(),
cost.total()
);
}

View file

@ -0,0 +1,405 @@
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Rate {
Missing,
Null,
Value(f64),
}
impl Rate {
fn value(self) -> Option<f64> {
match self {
Self::Value(value) => Some(value),
Self::Missing | Self::Null => None,
}
}
fn or(self, fallback: Self) -> Self {
if self.value().is_some() {
self
} else {
fallback
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rates {
pub input: Rate,
pub output: Rate,
pub cache_read: Rate,
pub cache_write: Rate,
pub cache_write_1h: Rate,
}
impl Rates {
pub const EMPTY: Self = Self {
input: Rate::Missing,
output: Rate::Missing,
cache_read: Rate::Missing,
cache_write: Rate::Missing,
cache_write_1h: Rate::Missing,
};
fn overlay(self, base: Self) -> Self {
Self {
input: self.input.or(base.input),
output: self.output.or(base.output),
cache_read: self.cache_read.or(base.cache_read),
cache_write: self.cache_write.or(base.cache_write),
cache_write_1h: self.cache_write_1h.or(base.cache_write_1h),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ServiceTier {
Standard,
Flex,
Priority,
Fast,
Ultrafast,
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ThresholdPolicy {
Exclusive,
Inclusive,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PromptConvention {
IncludesCache,
ExcludesCache,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Usage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub cache_write_5m_tokens: Option<u64>,
pub cache_write_1h_tokens: Option<u64>,
pub prompt_convention: PromptConvention,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TierRates {
pub tier: ServiceTier,
pub rates: Rates,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThresholdRates<'a> {
pub above_prompt_tokens: u64,
pub standard: Rates,
pub tiers: &'a [TierRates],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OffPeakRates {
pub start_utc_minute: u16,
pub end_utc_minute: u16,
pub rates: Rates,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pricing<'a> {
pub standard: Rates,
pub tiers: &'a [TierRates],
pub thresholds: &'a [ThresholdRates<'a>],
pub off_peak: Option<OffPeakRates>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Request {
pub usage: Usage,
pub service_tier: ServiceTier,
pub threshold_policy: ThresholdPolicy,
pub region_multiplier: Option<f64>,
pub billed_at_utc_minute: Option<u16>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cost {
pub uncached_input: f64,
pub cache_read: f64,
pub cache_write_5m: f64,
pub cache_write_1h: f64,
pub output: f64,
pub multiplier: f64,
pub rates: EffectiveRates,
}
impl Cost {
pub fn input(self) -> f64 {
(self.uncached_input + self.cache_read + self.cache_write_5m + self.cache_write_1h)
* self.multiplier
}
pub fn output(self) -> f64 {
self.output * self.multiplier
}
pub fn total(self) -> f64 {
self.input() + self.output()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EffectiveRates {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write_5m: f64,
pub cache_write_1h: f64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PricingError {
MissingInputRate,
MissingOutputRate,
InvalidRate,
InvalidRegionMultiplier,
InvalidBillingTime,
InvalidOffPeakWindow,
CacheExceedsPrompt,
InvalidCacheWriteDetails,
TokenCountOverflow,
DuplicateTier,
DuplicateThreshold,
DuplicateThresholdTier,
}
fn selected_tier(tier: ServiceTier) -> ServiceTier {
if tier == ServiceTier::Fast {
ServiceTier::Priority
} else {
tier
}
}
#[derive(Clone, Debug)]
struct CompiledThreshold {
above_prompt_tokens: u64,
standard: Rates,
tiers: Vec<TierRates>,
}
#[derive(Clone, Debug)]
pub struct PricingPlan {
standard: Rates,
tiers: Vec<TierRates>,
thresholds: Vec<CompiledThreshold>,
off_peak: Option<OffPeakRates>,
}
fn valid_rates(rates: Rates) -> bool {
[
rates.input,
rates.output,
rates.cache_read,
rates.cache_write,
rates.cache_write_1h,
]
.into_iter()
.all(|rate| {
rate.value()
.is_none_or(|value| value.is_finite() && value >= 0.0)
})
}
fn validate_tiers(tiers: &[TierRates], duplicate: PricingError) -> Result<(), PricingError> {
if tiers.iter().any(|entry| !valid_rates(entry.rates)) {
return Err(PricingError::InvalidRate);
}
if tiers.iter().enumerate().any(|(index, entry)| {
matches!(
entry.tier,
ServiceTier::Standard | ServiceTier::Unknown | ServiceTier::Fast
) || tiers[..index]
.iter()
.any(|previous| previous.tier == entry.tier)
}) {
return Err(duplicate);
}
Ok(())
}
pub fn compile(pricing: &Pricing<'_>) -> Result<PricingPlan, PricingError> {
if !valid_rates(pricing.standard) {
return Err(PricingError::InvalidRate);
}
validate_tiers(pricing.tiers, PricingError::DuplicateTier)?;
if let Some(window) = pricing.off_peak {
if window.start_utc_minute >= 1440
|| window.end_utc_minute > 1440
|| window.start_utc_minute >= window.end_utc_minute
{
return Err(PricingError::InvalidOffPeakWindow);
}
if !valid_rates(window.rates) {
return Err(PricingError::InvalidRate);
}
}
let mut thresholds: Vec<_> = pricing
.thresholds
.iter()
.map(|entry| {
if !valid_rates(entry.standard) {
return Err(PricingError::InvalidRate);
}
validate_tiers(entry.tiers, PricingError::DuplicateThresholdTier)?;
Ok(CompiledThreshold {
above_prompt_tokens: entry.above_prompt_tokens,
standard: entry.standard,
tiers: entry.tiers.to_vec(),
})
})
.collect::<Result<_, _>>()?;
thresholds.sort_unstable_by_key(|entry| entry.above_prompt_tokens);
if thresholds
.windows(2)
.any(|pair| pair[0].above_prompt_tokens == pair[1].above_prompt_tokens)
{
return Err(PricingError::DuplicateThreshold);
}
Ok(PricingPlan {
standard: pricing.standard,
tiers: pricing.tiers.to_vec(),
thresholds,
off_peak: pricing.off_peak,
})
}
impl PricingPlan {
fn resolve_rates(
&self,
request: &Request,
threshold_tokens: u64,
) -> Result<Rates, PricingError> {
let tier = selected_tier(request.service_tier);
let base = self
.tiers
.iter()
.find(|entry| tier != ServiceTier::Standard && entry.tier == tier)
.map_or(self.standard, |entry| entry.rates.overlay(self.standard));
let threshold = self.thresholds.iter().rev().find(|entry| {
threshold_tokens > entry.above_prompt_tokens
|| (request.threshold_policy == ThresholdPolicy::Inclusive
&& threshold_tokens == entry.above_prompt_tokens)
});
let selected = threshold.map_or(base, |entry| {
let standard = entry.standard.overlay(base);
entry
.tiers
.iter()
.find(|specific| tier != ServiceTier::Standard && specific.tier == tier)
.map_or(standard, |specific| specific.rates.overlay(standard))
});
match self.off_peak {
None => Ok(selected),
Some(window) => {
if window.start_utc_minute >= 1440
|| window.end_utc_minute > 1440
|| window.start_utc_minute >= window.end_utc_minute
{
return Err(PricingError::InvalidOffPeakWindow);
}
let minute = request
.billed_at_utc_minute
.ok_or(PricingError::InvalidBillingTime)?;
if minute >= 1440 {
return Err(PricingError::InvalidBillingTime);
}
if (window.start_utc_minute..window.end_utc_minute).contains(&minute) {
Ok(window.rates.overlay(selected))
} else {
Ok(selected)
}
}
}
}
fn checked_rate(rate: Rate, missing: PricingError) -> Result<f64, PricingError> {
let value = rate.value().ok_or(missing)?;
if !value.is_finite() || value < 0.0 {
return Err(PricingError::InvalidRate);
}
Ok(value)
}
pub fn calculate(&self, request: &Request) -> Result<Cost, PricingError> {
let usage = request.usage;
let cached = usage
.cache_read_tokens
.checked_add(usage.cache_write_tokens)
.ok_or(PricingError::TokenCountOverflow)?;
let (regular, threshold_tokens) = match usage.prompt_convention {
PromptConvention::IncludesCache => (
usage
.prompt_tokens
.checked_sub(cached)
.ok_or(PricingError::CacheExceedsPrompt)?,
usage.prompt_tokens,
),
PromptConvention::ExcludesCache => (
usage.prompt_tokens,
usage
.prompt_tokens
.checked_add(cached)
.ok_or(PricingError::TokenCountOverflow)?,
),
};
let writes = match (usage.cache_write_5m_tokens, usage.cache_write_1h_tokens) {
(None, None) => (usage.cache_write_tokens, 0),
(Some(five), Some(one)) if five.checked_add(one) == Some(usage.cache_write_tokens) => {
(five, one)
}
_ => return Err(PricingError::InvalidCacheWriteDetails),
};
let rates = self.resolve_rates(request, threshold_tokens)?;
let input = Self::checked_rate(rates.input, PricingError::MissingInputRate)?;
let output = Self::checked_rate(rates.output, PricingError::MissingOutputRate)?;
let read = Self::checked_rate(
rates.cache_read.or(rates.input),
PricingError::MissingInputRate,
)?;
let write = Self::checked_rate(
rates.cache_write.or(rates.input),
PricingError::MissingInputRate,
)?;
let write_1h = Self::checked_rate(
rates.cache_write_1h.or(rates.cache_write).or(rates.input),
PricingError::MissingInputRate,
)?;
let multiplier = request.region_multiplier.unwrap_or(1.0);
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(PricingError::InvalidRegionMultiplier);
}
let cost = Cost {
uncached_input: regular as f64 * input,
cache_read: usage.cache_read_tokens as f64 * read,
cache_write_5m: writes.0 as f64 * write,
cache_write_1h: writes.1 as f64 * write_1h,
output: usage.completion_tokens as f64 * output,
multiplier,
rates: EffectiveRates {
input,
output,
cache_read: read,
cache_write_5m: write,
cache_write_1h: write_1h,
},
};
if !cost.total().is_finite() {
return Err(PricingError::TokenCountOverflow);
}
Ok(cost)
}
}
pub fn calculate(pricing: &Pricing<'_>, request: &Request) -> Result<Cost, PricingError> {
compile(pricing)?.calculate(request)
}

View file

@ -0,0 +1,458 @@
use litellm_cost::{
OffPeakRates, Pricing, PricingError, PromptConvention, Rate, Rates, Request, ServiceTier,
ThresholdPolicy, ThresholdRates, TierRates, Usage, calculate, compile,
};
fn rates(input: Rate, output: Rate) -> Rates {
Rates {
input,
output,
..Rates::EMPTY
}
}
fn request() -> Request {
Request {
usage: Usage {
prompt_tokens: 100,
completion_tokens: 20,
cache_read_tokens: 25,
cache_write_tokens: 10,
cache_write_5m_tokens: None,
cache_write_1h_tokens: None,
prompt_convention: PromptConvention::IncludesCache,
},
service_tier: ServiceTier::Standard,
threshold_policy: ThresholdPolicy::Exclusive,
region_multiplier: None,
billed_at_utc_minute: None,
}
}
fn pricing(standard: Rates) -> Pricing<'static> {
Pricing {
standard,
tiers: &[],
thresholds: &[],
off_peak: None,
}
}
#[test]
fn breakdown_and_total_agree() {
let standard = Rates {
cache_read: Rate::Value(0.5),
cache_write: Rate::Value(3.0),
..rates(Rate::Value(2.0), Rate::Value(4.0))
};
let result = calculate(&pricing(standard), &request()).unwrap();
assert_eq!(result.uncached_input, 65.0 * 2.0);
assert_eq!(result.cache_read, 25.0 * 0.5);
assert_eq!(result.cache_write_5m, 10.0 * 3.0);
assert_eq!(result.output(), 20.0 * 4.0);
assert_eq!(result.total(), result.input() + result.output());
assert_eq!(result.rates.cache_read, 0.5);
}
#[test]
fn absent_null_and_zero_cache_rates_are_distinct() {
let base = rates(Rate::Value(2.0), Rate::Value(4.0));
for read in [Rate::Missing, Rate::Null] {
let standard = Rates {
cache_read: read,
..base
};
assert_eq!(
calculate(&pricing(standard), &request()).unwrap().input(),
200.0
);
}
let standard = Rates {
cache_read: Rate::Value(0.0),
cache_write: Rate::Value(0.0),
..base
};
assert_eq!(
calculate(&pricing(standard), &request()).unwrap().input(),
130.0
);
}
#[test]
fn equivalent_prompt_conventions_select_the_same_threshold() {
let threshold = ThresholdRates {
above_prompt_tokens: 90,
standard: rates(Rate::Value(5.0), Rate::Value(8.0)),
tiers: &[],
};
let specification = Pricing {
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
tiers: &[],
thresholds: &[threshold],
off_peak: None,
};
let included = request();
let excluded = Request {
usage: Usage {
prompt_tokens: 65,
prompt_convention: PromptConvention::ExcludesCache,
..included.usage
},
..included
};
let plan = compile(&specification).unwrap();
assert_eq!(plan.calculate(&included), plan.calculate(&excluded));
assert_eq!(plan.calculate(&included).unwrap().rates.input, 5.0);
}
#[test]
fn split_writes_and_invalid_accounting() {
let standard = Rates {
cache_read: Rate::Value(0.5),
cache_write: Rate::Value(3.0),
cache_write_1h: Rate::Value(5.0),
..rates(Rate::Value(2.0), Rate::Value(4.0))
};
let base = request();
let split = Request {
usage: Usage {
cache_write_5m_tokens: Some(4),
cache_write_1h_tokens: Some(6),
..base.usage
},
..base
};
let result = calculate(&pricing(standard), &split).unwrap();
assert_eq!(result.cache_write_5m, 12.0);
assert_eq!(result.cache_write_1h, 30.0);
let overlapping = Request {
usage: Usage {
prompt_tokens: 30,
..split.usage
},
..split
};
assert_eq!(
calculate(&pricing(standard), &overlapping),
Err(PricingError::CacheExceedsPrompt)
);
let incomplete = Request {
usage: Usage {
cache_write_1h_tokens: None,
..split.usage
},
..split
};
assert_eq!(
calculate(&pricing(standard), &incomplete),
Err(PricingError::InvalidCacheWriteDetails)
);
}
#[test]
fn threshold_tiers_and_boundaries() {
let priority = TierRates {
tier: ServiceTier::Priority,
rates: rates(Rate::Value(3.0), Rate::Missing),
};
let threshold = ThresholdRates {
above_prompt_tokens: 100,
standard: rates(Rate::Value(5.0), Rate::Value(8.0)),
tiers: &[
TierRates {
tier: ServiceTier::Priority,
rates: rates(Rate::Value(7.0), Rate::Missing),
},
TierRates {
tier: ServiceTier::Flex,
rates: rates(Rate::Value(6.0), Rate::Missing),
},
],
};
let specification = Pricing {
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
tiers: &[priority],
thresholds: &[threshold],
off_peak: None,
};
let base = request();
let no_cache = Request {
usage: Usage {
cache_read_tokens: 0,
cache_write_tokens: 0,
..base.usage
},
..base
};
let fast = Request {
service_tier: ServiceTier::Fast,
..no_cache
};
let inclusive = Request {
threshold_policy: ThresholdPolicy::Inclusive,
..fast
};
let flex = Request {
service_tier: ServiceTier::Flex,
..inclusive
};
assert_eq!(calculate(&specification, &no_cache).unwrap().input(), 200.0);
assert_eq!(calculate(&specification, &fast).unwrap().input(), 300.0);
assert_eq!(
calculate(&specification, &inclusive).unwrap().input(),
700.0
);
assert_eq!(calculate(&specification, &flex).unwrap().input(), 600.0);
}
#[test]
fn compile_rejects_ambiguous_rates() {
let duplicate = ThresholdRates {
above_prompt_tokens: 100,
standard: Rates::EMPTY,
tiers: &[],
};
let specification = Pricing {
standard: rates(Rate::Value(1.0), Rate::Value(1.0)),
tiers: &[],
thresholds: &[duplicate, duplicate],
off_peak: None,
};
assert_eq!(
compile(&specification).err(),
Some(PricingError::DuplicateThreshold)
);
let invalid = pricing(rates(Rate::Value(f64::NAN), Rate::Value(1.0)));
assert_eq!(compile(&invalid).err(), Some(PricingError::InvalidRate));
}
#[test]
fn off_peak_is_one_non_wrapping_utc_window() {
let specification = Pricing {
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
tiers: &[],
thresholds: &[],
off_peak: Some(OffPeakRates {
start_utc_minute: 60,
end_utc_minute: 120,
rates: rates(Rate::Value(1.0), Rate::Value(2.0)),
}),
};
let base = request();
let start = Request {
billed_at_utc_minute: Some(60),
..base
};
let end = Request {
billed_at_utc_minute: Some(120),
..base
};
assert_eq!(
calculate(&specification, &base),
Err(PricingError::InvalidBillingTime)
);
assert_eq!(calculate(&specification, &start).unwrap().input(), 100.0);
assert_eq!(calculate(&specification, &end).unwrap().input(), 200.0);
}
#[test]
fn missing_rates_and_free_rates_remain_distinct() {
let base = request();
let empty = Request {
usage: Usage {
prompt_tokens: 0,
completion_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
..base.usage
},
..base
};
assert_eq!(
calculate(&pricing(Rates::EMPTY), &empty),
Err(PricingError::MissingInputRate)
);
assert_eq!(
calculate(&pricing(rates(Rate::Value(0.0), Rate::Missing)), &empty),
Err(PricingError::MissingOutputRate)
);
assert_eq!(
calculate(&pricing(rates(Rate::Value(0.0), Rate::Value(0.0))), &empty)
.unwrap()
.total(),
0.0
);
}
#[test]
fn matches_executed_python_reference_cases() {
for row in include_str!("python_reference.tsv")
.lines()
.filter(|line| !line.starts_with('#'))
{
let fields: Vec<_> = row.split('\t').collect();
let count = |index: usize| fields[index].parse::<u64>().unwrap();
let number = |index: usize| fields[index].parse::<f64>().unwrap();
let optional_rate = |index: usize| {
if fields[index].is_empty() {
Rate::Missing
} else {
Rate::Value(number(index))
}
};
let threshold = ThresholdRates {
above_prompt_tokens: if fields[9].is_empty() { 0 } else { count(9) },
standard: rates(optional_rate(10), optional_rate(11)),
tiers: &[],
};
let thresholds = if fields[9].is_empty() {
&[][..]
} else {
std::slice::from_ref(&threshold)
};
let specification = Pricing {
standard: Rates {
cache_read: optional_rate(7),
cache_write: optional_rate(8),
..rates(Rate::Value(number(5)), Rate::Value(number(6)))
},
tiers: &[],
thresholds,
off_peak: None,
};
let base = request();
let input = Request {
usage: Usage {
prompt_tokens: count(1),
completion_tokens: count(2),
cache_read_tokens: count(3),
cache_write_tokens: count(4),
..base.usage
},
..base
};
let actual = calculate(&specification, &input).unwrap();
assert_eq!(actual.input(), number(12), "{}", fields[0]);
assert_eq!(actual.output(), number(13), "{}", fields[0]);
}
}
proptest::proptest! {
#[test]
fn equivalent_usage_conventions_and_breakdown_agree(
regular in 0_u64..1000,
read in 0_u64..1000,
write in 0_u64..1000,
output in 0_u64..1000,
) {
let threshold = ThresholdRates {
above_prompt_tokens: 1000,
standard: rates(Rate::Value(5.0), Rate::Value(8.0)),
tiers: &[],
};
let specification = Pricing {
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
tiers: &[],
thresholds: &[threshold],
off_peak: None,
};
let base = request();
let included = Request {
usage: Usage {
prompt_tokens: regular + read + write,
completion_tokens: output,
cache_read_tokens: read,
cache_write_tokens: write,
..base.usage
},
..base
};
let excluded = Request {
usage: Usage {
prompt_tokens: regular,
prompt_convention: PromptConvention::ExcludesCache,
..included.usage
},
..included
};
let plan = compile(&specification).unwrap();
let left = plan.calculate(&included).unwrap();
let right = plan.calculate(&excluded).unwrap();
proptest::prop_assert_eq!(left, right);
proptest::prop_assert_eq!(left.total(), left.input() + left.output());
}
}
#[test]
fn regional_multiplier_applies_after_input_components_are_summed() {
let standard = Rates {
cache_read: Rate::Value(0.5),
cache_write: Rate::Value(3.0),
..rates(Rate::Value(2.0), Rate::Value(4.0))
};
let base = request();
let regional = Request {
region_multiplier: Some(1.1),
..base
};
let result = calculate(&pricing(standard), &regional).unwrap();
assert_eq!(result.input(), (65.0 * 2.0 + 25.0 * 0.5 + 10.0 * 3.0) * 1.1);
assert_eq!(result.output(), 20.0 * 4.0 * 1.1);
let invalid = Request {
region_multiplier: Some(f64::NAN),
..base
};
assert_eq!(
calculate(&pricing(standard), &invalid),
Err(PricingError::InvalidRegionMultiplier)
);
}
#[test]
fn compilation_sorts_thresholds_and_rejects_duplicate_tiers() {
let high = ThresholdRates {
above_prompt_tokens: 200,
standard: rates(Rate::Value(7.0), Rate::Missing),
tiers: &[],
};
let low = ThresholdRates {
above_prompt_tokens: 100,
standard: rates(Rate::Value(5.0), Rate::Missing),
tiers: &[],
};
let specification = Pricing {
standard: rates(Rate::Value(2.0), Rate::Value(4.0)),
tiers: &[],
thresholds: &[high, low],
off_peak: None,
};
let base = request();
let above_both = Request {
usage: Usage {
prompt_tokens: 201,
cache_read_tokens: 0,
cache_write_tokens: 0,
..base.usage
},
..base
};
assert_eq!(
compile(&specification)
.unwrap()
.calculate(&above_both)
.unwrap()
.rates
.input,
7.0
);
let duplicate = TierRates {
tier: ServiceTier::Flex,
rates: Rates::EMPTY,
};
let invalid = Pricing {
tiers: &[duplicate, duplicate],
thresholds: &[],
..specification
};
assert_eq!(compile(&invalid).err(), Some(PricingError::DuplicateTier));
}

View file

@ -0,0 +1,96 @@
import subprocess
from dataclasses import dataclass
from pathlib import Path
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
from litellm.types.utils import Usage
@dataclass(frozen=True, slots=True)
class Case:
name: str
prompt: int
completion: int
cache_read: int
cache_write: int
input_rate: float
output_rate: float
cache_read_rate: float | None = None
cache_write_rate: float | None = None
threshold: int | None = None
threshold_input_rate: float | None = None
threshold_output_rate: float | None = None
CASES = (
Case("ordinary", 100, 20, 0, 0, 2.0, 4.0),
Case("cache_fallback", 100, 20, 25, 10, 2.0, 4.0),
Case("cache_specific", 100, 20, 25, 10, 2.0, 4.0, 0.5, 3.0),
Case("free_cache", 100, 20, 25, 10, 2.0, 4.0, 0.0, 0.0),
Case("threshold_below", 99, 20, 0, 0, 2.0, 4.0, threshold=100, threshold_input_rate=5.0, threshold_output_rate=8.0),
Case("threshold_at", 100, 20, 0, 0, 2.0, 4.0, threshold=100, threshold_input_rate=5.0, threshold_output_rate=8.0),
Case(
"threshold_above", 101, 20, 0, 0, 2.0, 4.0, threshold=100, threshold_input_rate=5.0, threshold_output_rate=8.0
),
Case(
"cache_threshold_above",
101,
20,
25,
10,
2.0,
4.0,
threshold=100,
threshold_input_rate=5.0,
threshold_output_rate=8.0,
),
)
def reference(case: Case) -> tuple[float, float]:
info = {"input_cost_per_token": case.input_rate, "output_cost_per_token": case.output_rate}
if case.cache_read_rate is not None:
info["cache_read_input_token_cost"] = case.cache_read_rate
if case.cache_write_rate is not None:
info["cache_creation_input_token_cost"] = case.cache_write_rate
if case.threshold is not None:
info[f"input_cost_per_token_above_{case.threshold}_tokens"] = case.threshold_input_rate
info[f"output_cost_per_token_above_{case.threshold}_tokens"] = case.threshold_output_rate
details = {"cached_tokens": case.cache_read, "cache_write_tokens": case.cache_write}
usage = Usage(prompt_tokens=case.prompt, completion_tokens=case.completion, prompt_tokens_details=details)
return generic_cost_per_token(
model="synthetic",
usage=usage,
custom_llm_provider="openai",
model_info=info,
)
def main() -> None:
revision = subprocess.check_output(("git", "rev-parse", "HEAD"), text=True).strip()
rows = ("# Python reference commit: " + revision,) + tuple(
"\t".join(
str(value) if value is not None else ""
for value in (
case.name,
case.prompt,
case.completion,
case.cache_read,
case.cache_write,
case.input_rate,
case.output_rate,
case.cache_read_rate,
case.cache_write_rate,
case.threshold,
case.threshold_input_rate,
case.threshold_output_rate,
*reference(case),
)
)
for case in CASES
)
Path(__file__).with_name("python_reference.tsv").write_text("\n".join(rows) + "\n")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,9 @@
# Python reference commit: dc4be2fd987c993aefcf16444e34f960c12c8627
ordinary 100 20 0 0 2.0 4.0 200.0 80.0
cache_fallback 100 20 25 10 2.0 4.0 200.0 80.0
cache_specific 100 20 25 10 2.0 4.0 0.5 3.0 172.5 80.0
free_cache 100 20 25 10 2.0 4.0 0.0 0.0 130.0 80.0
threshold_below 99 20 0 0 2.0 4.0 100 5.0 8.0 198.0 80.0
threshold_at 100 20 0 0 2.0 4.0 100 5.0 8.0 200.0 80.0
threshold_above 101 20 0 0 2.0 4.0 100 5.0 8.0 505.0 160.0
cache_threshold_above 101 20 25 10 2.0 4.0 100 5.0 8.0 505.0 160.0
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -0,0 +1,25 @@
[package]
name = "litellm-model-catalog"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[features]
schema = ["dep:schemars"]
[dependencies]
indexmap = { version = "2.14.0", features = ["serde"] }
schemars = { version = "1.0", optional = true }
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
criterion.workspace = true
rstest.workspace = true
litellm-model-catalog = { path = ".", features = ["schema"] }
[[bench]]
name = "catalog"
harness = false

View file

@ -0,0 +1,25 @@
# Model catalog
`litellm-model-catalog` builds an immutable snapshot from caller supplied JSON bytes. It has no network, Python, registration, or refresh behavior. The caller supplies optional source, revision, and ETag provenance. Parse and validation are separate so small synthetic catalogs can use explicit integrity limits
The parser treats `sample_spec` and `fallback_generalizations` as reserved top level metadata. `fallback_rules()` exposes the typed rule array when present; this crate does not execute regex generalizations. Model entries retain all JSON fields except `aliases`, including unknown fields. `field()` returns `None` for an absent key and a JSON null, false, or zero value for a present key. The returned values are borrowed, so callers cannot mutate the snapshot
Each entry also deserializes into `ModelInfo`, a typed mirror of `model_prices_and_context_window.schema.json`'s `modelEntry` definition, reachable via `ModelEntry::info()`. All schema fields are optional on `ModelInfo`, including `litellm_provider` which the schema marks required, so small synthetic catalogs still parse. Unknown fields are not part of `ModelInfo`; they remain on `fields()`. Building with the `schema` feature adds `schemars` derives and exposes `model_entry_json_schema()` for emitting the entry's JSON Schema. Parse and validation failures are reported by the `Error` enum in `error.rs`, while catalog logic lives in `catalog.rs`
The integration tests read the repository's catalog and schema files at test time, assert every entry round-trips through `ModelInfo`, and verify that the generated schema's properties match the repository schema
Aliases point to their canonical entries. An alias that exactly matches any canonical key is skipped; the first canonical entry claiming an alias wins. Invalid alias lists and nonstring names are skipped and reported by `alias_issues()`. Exact lookup wins. For a case insensitive miss, the last key with the same lowercase spelling wins, following Python's lowercase map built after aliases are appended. This uses Rust Unicode lowercasing, which can differ from Python for unusual Unicode model IDs
`validate()` counts canonical entries before alias expansion and excludes both reserved keys. It enforces an explicit minimum and backup shrink ratio, with Python defaults of 50 models and 0.5. Parsing rejects nonobject model entries and known fields with the wrong JSON type, but ignores unknown fields. It does not enforce every constraint in the JSON schema, calculate prices, resolve providers, or check provenance authenticity. The caller decides how to handle validation failures
This snapshot does not represent Python's live mutable `litellm.model_cost`, nested dict and list mutation, or mutation of dicts previously returned by Python APIs. It has no bridge or runtime integration
## Benchmarks
`cargo bench -p litellm-model-catalog --bench catalog` measures parsing plus alias indexing and exact lookup. For a local Python baseline on the same fixture, use:
```sh
python3 -m timeit -s 'import json, pathlib; body = pathlib.Path("../model_prices_and_context_window.json").read_bytes()' 'json.loads(body)'
```
Run these commands from `litellm-rust`. Python's command measures JSON loading only, without alias expansion or snapshot construction. The Rust benchmark does not include future Python object materialization, so these numbers are not an end to end runtime comparison

View file

@ -0,0 +1,21 @@
use criterion::{Criterion, criterion_group, criterion_main};
use litellm_model_catalog::{Catalog, Provenance};
use std::hint::black_box;
fn benchmarks(c: &mut Criterion) {
let body = include_bytes!("../../../../model_prices_and_context_window.json");
c.bench_function("parse_current_catalog", |b| {
b.iter(|| Catalog::parse(black_box(body), Provenance::default()).unwrap())
});
let catalog = Catalog::parse(body, Provenance::default()).unwrap();
let key = catalog
.model_names()
.next()
.expect("catalog must have a benchmark key");
c.bench_function("lookup_catalog_key", |b| {
b.iter(|| black_box(&catalog).lookup(black_box(key)))
});
}
criterion_group!(benches, benchmarks);
criterion_main!(benches);

View file

@ -0,0 +1,241 @@
use crate::error::Error;
use crate::model_info::{FallbackGeneralizations, FallbackRule, ModelInfo};
use indexmap::IndexMap;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Provenance {
pub source: Option<String>,
pub revision: Option<String>,
pub etag: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IntegrityLimits {
pub backup_model_count: usize,
pub min_model_count: usize,
pub min_backup_ratio: f64,
}
impl IntegrityLimits {
pub fn python_defaults(backup_model_count: usize) -> Self {
Self {
backup_model_count,
min_model_count: 50,
min_backup_ratio: 0.5,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AliasIssue {
InvalidList { model: String },
InvalidName { model: String },
CanonicalCollision { model: String, alias: String },
AliasCollision { model: String, alias: String },
}
#[derive(Clone, Debug)]
pub struct ModelEntry {
fields: Map<String, Value>,
info: ModelInfo,
}
impl ModelEntry {
pub fn field(&self, name: &str) -> Option<&Value> {
self.fields.get(name)
}
pub fn fields(&self) -> &Map<String, Value> {
&self.fields
}
/// The entry deserialized into the typed mirror of the catalog schema.
pub fn info(&self) -> &ModelInfo {
&self.info
}
}
#[derive(Clone, Copy, Debug)]
pub struct ModelMatch<'a> {
pub matched_key: &'a str,
pub canonical_key: &'a str,
pub entry: &'a ModelEntry,
}
#[derive(Debug)]
pub struct Catalog {
entries: IndexMap<String, ModelEntry>,
aliases: IndexMap<String, String>,
lowercase_keys: HashMap<String, String>,
sample_spec: Option<Value>,
fallback_generalizations: Option<FallbackGeneralizations>,
provenance: Provenance,
alias_issues: Vec<AliasIssue>,
}
impl Catalog {
pub fn parse(body: &[u8], provenance: Provenance) -> Result<Self, Error> {
let root: IndexMap<String, Value> = serde_json::from_slice(body)?;
if root.is_empty() {
return Err(Error::Empty);
}
let mut entries = IndexMap::with_capacity(root.len());
let mut alias_lists = Vec::new();
let mut alias_issues = Vec::new();
let mut sample_spec = None;
let mut fallback_generalizations = None;
for (name, value) in root {
match name.as_str() {
"sample_spec" => {
sample_spec = Some(value);
continue;
}
"fallback_generalizations" => {
fallback_generalizations =
Some(serde_json::from_value::<FallbackGeneralizations>(value)?);
continue;
}
_ => {}
}
let Value::Object(ref object) = value else {
return Err(Error::EntryNotObject { model: name });
};
let info = ModelInfo::deserialize(object)?;
let Value::Object(mut fields) = value else {
unreachable!("value checked is_object above")
};
if let Some(aliases) = fields.remove("aliases")
&& !aliases.is_null()
{
match aliases {
Value::Array(names) => alias_lists.push((name.clone(), names)),
_ => alias_issues.push(AliasIssue::InvalidList {
model: name.clone(),
}),
}
}
entries.insert(name, ModelEntry { fields, info });
}
let mut aliases = IndexMap::new();
for (model, names) in alias_lists {
for name in names {
let Value::String(alias) = name else {
alias_issues.push(AliasIssue::InvalidName {
model: model.clone(),
});
continue;
};
if entries.contains_key(&alias) {
alias_issues.push(AliasIssue::CanonicalCollision {
model: model.clone(),
alias,
});
} else if aliases.contains_key(&alias) {
alias_issues.push(AliasIssue::AliasCollision {
model: model.clone(),
alias,
});
} else {
aliases.insert(alias, model.clone());
}
}
}
let lowercase_keys = entries
.keys()
.chain(aliases.keys())
.map(|key| (key.to_lowercase(), key.clone()))
.collect();
Ok(Self {
entries,
aliases,
lowercase_keys,
sample_spec,
fallback_generalizations,
provenance,
alias_issues,
})
}
pub fn validate(&self, limits: IntegrityLimits) -> Result<(), Error> {
if !limits.min_backup_ratio.is_finite() || !(0.0..=1.0).contains(&limits.min_backup_ratio) {
return Err(Error::InvalidRatio);
}
let actual = self.entries.len();
if actual < limits.min_model_count {
return Err(Error::BelowMinimum {
actual,
minimum: limits.min_model_count,
});
}
if limits.backup_model_count > 0
&& (actual as f64) < (limits.backup_model_count as f64) * limits.min_backup_ratio
{
return Err(Error::Shrunk {
actual,
backup: limits.backup_model_count,
ratio: limits.min_backup_ratio,
});
}
Ok(())
}
pub fn lookup(&self, key: &str) -> Option<ModelMatch<'_>> {
let matched_key = if self.entries.contains_key(key) || self.aliases.contains_key(key) {
key
} else {
self.lowercase_keys.get(&key.to_lowercase())?.as_str()
};
let canonical_key = self
.aliases
.get(matched_key)
.map(String::as_str)
.unwrap_or(matched_key);
let (canonical_key, entry) = self.entries.get_key_value(canonical_key)?;
let matched_key = self
.entries
.get_key_value(matched_key)
.map(|(key, _)| key.as_str())
.or_else(|| {
self.aliases
.get_key_value(matched_key)
.map(|(key, _)| key.as_str())
})?;
Some(ModelMatch {
matched_key,
canonical_key,
entry,
})
}
pub fn model_count(&self) -> usize {
self.entries.len()
}
pub fn model_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
pub fn alias_count(&self) -> usize {
self.aliases.len()
}
pub fn aliases(&self) -> &IndexMap<String, String> {
&self.aliases
}
pub fn alias_issues(&self) -> &[AliasIssue] {
&self.alias_issues
}
pub fn sample_spec(&self) -> Option<&Value> {
self.sample_spec.as_ref()
}
pub fn fallback_generalizations(&self) -> Option<&FallbackGeneralizations> {
self.fallback_generalizations.as_ref()
}
pub fn fallback_rules(&self) -> Option<&[FallbackRule]> {
Some(self.fallback_generalizations.as_ref()?.rules.as_slice())
}
pub fn provenance(&self) -> &Provenance {
&self.provenance
}
}

View file

@ -0,0 +1,28 @@
use thiserror::Error;
/// Failures from parsing or validating a catalog snapshot.
#[derive(Debug, Error)]
pub enum Error {
/// The body is not valid JSON, or a model entry fails typed deserialization.
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
/// The catalog has no entries at all.
#[error("catalog is empty")]
Empty,
/// A non-reserved top level value is not a JSON object.
#[error("model {model:?} must be an object")]
EntryNotObject { model: String },
/// Canonical entry count is under the configured minimum.
#[error("catalog has {actual} models, below minimum {minimum}")]
BelowMinimum { actual: usize, minimum: usize },
/// Canonical entry count is under the configured backup shrink ratio.
#[error("catalog has {actual} models, below {ratio} of backup count {backup}")]
Shrunk {
actual: usize,
backup: usize,
ratio: f64,
},
/// The configured minimum backup ratio is not finite or outside `[0, 1]`.
#[error("minimum backup ratio must be finite and between zero and one")]
InvalidRatio,
}

View file

@ -0,0 +1,16 @@
mod catalog;
mod error;
mod model_info;
#[cfg(feature = "schema")]
mod schema;
pub use catalog::{AliasIssue, Catalog, IntegrityLimits, ModelEntry, ModelMatch, Provenance};
pub use error::Error;
pub use model_info::{
AudioFormat, FallbackGeneralizations, FallbackRule, InputModality, Mode, ModelInfo,
OffPeakPricing, OffPeakWindow, OutputModality, ReasoningEffort, SearchContextCostPerQuery,
TieredRate, UtcHours, VertexAiAudioApi, WebSearchBillingUnit, Weekday,
};
#[cfg(feature = "schema")]
pub use schema::model_entry_json_schema;

View file

@ -0,0 +1,665 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
/// Primary API surface / task type of the model.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum Mode {
AudioSpeech,
AudioTranscription,
Chat,
Completion,
Embedding,
Evaluation,
Guardrail,
ImageEdit,
ImageGeneration,
Moderation,
Ocr,
Realtime,
Rerank,
Responses,
Search,
VectorStore,
VideoGeneration,
}
/// Reasoning effort level accepted or applied by the model.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
Xhigh,
Max,
}
/// Gemini audio generation API the model is served through.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum VertexAiAudioApi {
LyriaPredict,
LyriaInteractions,
}
/// Whether web search is billed per query or per prompt.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum WebSearchBillingUnit {
PerQuery,
PerPrompt,
}
/// Audio container format the model can return.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum AudioFormat {
Mp3,
Wav,
}
/// Input modality the model accepts.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum InputModality {
Text,
Image,
Audio,
Video,
}
/// Output modality the model can produce.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum OutputModality {
Text,
Image,
Audio,
Video,
Code,
}
/// UTC "HH:MM-HH:MM" window, or a list of them; a window may wrap past midnight.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum UtcHours {
Single(String),
Multiple(Vec<String>),
}
/// ISO-8601 weekday number (1 = Monday .. 7 = Sunday) or English day name.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum Weekday {
Number(u8),
Name(String),
}
/// One off-peak window entry inside `windows`.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct OffPeakWindow {
pub hours_utc: UtcHours,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weekdays: Option<Vec<Weekday>>,
}
/// Rates that replace the same-named base fields inside the stated UTC windows.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct OffPeakPricing {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hours_utc: Option<UtcHours>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub windows: Option<Vec<OffPeakWindow>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weekday_timezone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_reasoning_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost: Option<f64>,
}
/// USD cost per web search query, keyed by search context size.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SearchContextCostPerQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_context_size_low: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_context_size_medium: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_context_size_high: Option<f64>,
}
/// One tier of a context-length or result-count tiered rate.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TieredRate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range: Option<[f64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_results_range: Option<[f64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_reasoning_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_query: Option<f64>,
}
/// One regex rule generalizing unknown model ids to known families.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct FallbackRule {
pub name: String,
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
/// Regex rules that generalize unknown model ids to known families; not a model entry.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct FallbackGeneralizations {
pub rules: Vec<FallbackRule>,
}
/// Typed mirror of one catalog model entry.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ModelInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotation_cost_per_page: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotation_cost_per_page_batches: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio_transcription_config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bedrock_converse_supports_strict_tools: Option<bool>,
/// Highest reasoning effort the Bedrock output_config accepts for this model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bedrock_output_config_effort_ceiling: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_audio_token_cost: Option<f64>,
/// USD per token written to the provider's prompt cache.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_128k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_1hr: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_1hr_above_200k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_200k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_256k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_272k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_272k_tokens_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_272k_tokens_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_above_272k_tokens_priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_creation_input_token_cost_priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_audio_token_cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_image_token_cost: Option<f64>,
/// USD per prompt token served from the provider's prompt cache.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_128k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_200k_tokens: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_200k_tokens_priority: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_256k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_272k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_272k_tokens_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_272k_tokens_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_272k_tokens_priority: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_above_512k_tokens: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_input_token_cost_priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub citation_cost_per_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code_interpreter_cost_per_session: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
/// Reasoning effort the provider applies when the request omits reasoning_effort. Gates whether a non-default temperature or the top_p/logprobs sampling params are accepted, which hold only when the effort resolves to 'none'.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_reasoning_effort: Option<ReasoningEffort>,
/// Date the provider deprecates the model, YYYY-MM-DD.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deprecation_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_audio_only_live: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini_native_audio: Option<bool>,
/// USD per Grounding with Google Maps request; billed per query or per prompt per web_search_billing_unit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub google_maps_grounding_cost_per_query: Option<f64>,
/// USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guardrail_cost_per_unit: Option<BTreeMap<String, f64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_audio_per_second: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_audio_per_second_above_128k_tokens: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_audio_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_audio_token_batches: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_audio_token_priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_character: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_character_above_128k_tokens: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_image: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_image_above_128k_tokens: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_image_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_image_token_batches: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_pixel: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_query: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_request: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_second: Option<f64>,
/// USD per prompt token.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_128k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_200k_tokens: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_200k_tokens_priority: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_256k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_272k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_272k_tokens_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_272k_tokens_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_272k_tokens_priority: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_above_512k_tokens: Option<f64>,
/// USD per prompt token via the provider's batch API.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_batches: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_cache_hit: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_token_priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_video_per_second: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_video_per_second_above_128k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_video_per_second_above_15s_interval: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_video_per_second_above_8s_interval: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_video_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_cost_per_video_token_batches: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_dbu_cost_per_token: Option<f64>,
/// LiteLLM provider slug; one of https://docs.litellm.ai/docs/providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub litellm_provider: Option<String>,
/// Maximum prompt/context tokens the model accepts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_input_tokens: Option<u64>,
/// Maximum tokens the model can generate in one response.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
/// Legacy field: max output tokens if the provider specifies it, else max input tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
/// Free-form notes about the entry (e.g. pricing derivation).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<BTreeMap<String, Value>>,
/// Primary API surface / task type of the model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<Mode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ocr_cost_per_credit: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ocr_cost_per_page: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ocr_cost_per_page_batches: Option<f64>,
/// Rates that replace the same-named base fields while the request falls inside the stated UTC windows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub off_peak_pricing: Option<OffPeakPricing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_audio_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_character: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_character_above_128k_tokens: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_image: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_image_1024: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_image_1536: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_image_512: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_image_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_pixel: Option<f64>,
/// USD per reasoning/thinking token, when billed separately.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_reasoning_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second_1080p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second_2k: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second_480p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second_4k: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second_720p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_second_768p: Option<f64>,
/// USD per generated token.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_128k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_200k_tokens: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_200k_tokens_priority: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_256k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_272k_tokens: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_272k_tokens_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_272k_tokens_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_272k_tokens_priority: Option<f64>,
/// Rate applied once the prompt exceeds the token threshold in the field name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_above_512k_tokens: Option<f64>,
/// USD per generated token via the provider's batch API.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_batches: Option<f64>,
/// Flex service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_flex: Option<f64>,
/// Priority service-tier rate for the same-named base field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_token_priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_video_per_second: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_cost_per_video_token: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_dbu_cost_per_token: Option<f64>,
/// Embedding dimension for embedding models.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_vector_size: Option<u64>,
/// Smallest prefix the provider will actually cache; absent means the provider default applies.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_min_tokens: Option<u64>,
/// Provider-internal routing hints (e.g. bedrock_invocation_schema).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_specific_entry: Option<BTreeMap<String, Value>>,
/// Exact reasoning_effort levels this deployment accepts; wins over supports_* flags.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort_levels: Option<Vec<ReasoningEffort>>,
/// Multiplier applied to all token costs when served from a non-global Vertex AI endpoint (e.g. 1.10 = +10%).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regional_endpoint_uplift_multiplier: Option<f64>,
/// Multiplier applied to all token costs for EU data residency (e.g. 1.10 = +10%).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regional_processing_uplift_multiplier_eu: Option<f64>,
/// Multiplier applied to all token costs for US data residency (e.g. 1.10 = +10%).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regional_processing_uplift_multiplier_us: Option<f64>,
/// Provider default requests-per-minute limit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rpm: Option<u64>,
/// USD cost per web search query, keyed by search context size.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_context_cost_per_query: Option<SearchContextCostPerQuery>,
/// URL of the provider pricing/model page this entry was taken from.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// Audio container formats the model can return.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_audio_formats: Option<Vec<AudioFormat>>,
/// OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_endpoints: Option<Vec<String>>,
/// Input modalities the model accepts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_modalities: Option<Vec<InputModality>>,
/// Output modalities the model can produce.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_output_modalities: Option<Vec<OutputModality>>,
/// Cloud regions the model is available in ('global' or region ids).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supported_regions: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_adaptive_thinking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_anthropic_compaction: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_anthropic_thinking_payload: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_assistant_prefill: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_audio_input: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_audio_output: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_computer_use: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_embedding_image_input: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_fast_mode: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_forced_tool_use: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_function_calling: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_image_input: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_image_size: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_legacy_thinking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_low_reasoning_effort: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_max_reasoning_effort: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_mid_conversation_system: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_minimal_reasoning_effort: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_multimodal: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_native_streaming: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_native_structured_output: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_none_reasoning_effort: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_nova_canvas_image_edit: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_output_config: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_parallel_function_calling: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_parallel_tool_use_config: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_pdf_input: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_prompt_cache_breakpoint: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_prompt_caching: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_reasoning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_response_schema: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_sampling_params: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_speed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_system_messages: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_thinking_cache_preservation: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_tool_choice: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_tool_search: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_url_context: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_video_input: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_vision: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_web_search: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_xhigh_reasoning_effort: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_always_on: Option<bool>,
/// Context-length or result-count tiered rates; each tier's costs apply within its range.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tiered_pricing: Option<Vec<TieredRate>>,
/// Provider default tokens-per-minute limit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tpm: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_openai_responses_path: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uses_embed_content: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vertex_ai_audio_api: Option<VertexAiAudioApi>,
/// Whether web search is billed per query or per prompt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_search_billing_unit: Option<WebSearchBillingUnit>,
}

View file

@ -0,0 +1,7 @@
use crate::model_info::ModelInfo;
/// JSON Schema for one catalog model entry, mirroring
/// `model_prices_and_context_window.schema.json`'s `modelEntry` definition.
pub fn model_entry_json_schema() -> schemars::Schema {
schemars::schema_for!(ModelInfo)
}

View file

@ -0,0 +1,253 @@
use std::path::{Path, PathBuf};
use litellm_model_catalog::{AliasIssue, Catalog, Error, IntegrityLimits, Provenance};
use rstest::{fixture, rstest};
use serde_json::json;
const ALPHA_FIXTURE: &[u8] = br#"{
"sample_spec":{"explanation":"example"},
"fallback_generalizations":{"rules":[{"name":"family","pattern":"^new-","model_info":{"mode":"chat"}}]},
"Alpha":{"litellm_provider":"test","aliases":["short"],"price":0,"enabled":false,
"optional":null,"unknown":{"nested":[1,{"x":true}]}}
}"#;
#[fixture]
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..")
}
#[fixture]
fn current_catalog(repo_root: PathBuf) -> Catalog {
let body = std::fs::read(repo_root.join("model_prices_and_context_window.json")).unwrap();
Catalog::parse(&body, Provenance::default()).unwrap()
}
#[fixture]
fn backup_catalog(repo_root: PathBuf) -> Catalog {
let body = std::fs::read(repo_root.join("litellm/model_prices_and_context_window_backup.json"))
.unwrap();
Catalog::parse(&body, Provenance::default()).unwrap()
}
#[fixture]
fn fixture_catalog() -> Catalog {
Catalog::parse(
ALPHA_FIXTURE,
Provenance {
source: Some("fixture".into()),
revision: Some("rev".into()),
etag: None,
},
)
.unwrap()
}
#[rstest]
fn preserves_fields_and_metadata(fixture_catalog: Catalog) {
let catalog = fixture_catalog;
let entry = catalog.lookup("SHORT").unwrap();
assert_eq!(entry.canonical_key, "Alpha");
assert_eq!(entry.matched_key, "short");
assert_eq!(entry.entry.field("price"), Some(&json!(0)));
assert_eq!(entry.entry.field("enabled"), Some(&json!(false)));
assert_eq!(entry.entry.field("optional"), Some(&json!(null)));
assert_eq!(entry.entry.field("missing"), None);
assert_eq!(
entry.entry.field("unknown"),
Some(&json!({"nested":[1,{"x":true}]}))
);
assert_eq!(entry.entry.field("aliases"), None);
assert_eq!(entry.entry.info().litellm_provider.as_deref(), Some("test"));
assert_eq!(
catalog.sample_spec(),
Some(&json!({"explanation":"example"}))
);
assert_eq!(catalog.fallback_rules().unwrap().len(), 1);
assert_eq!(catalog.provenance().revision.as_deref(), Some("rev"));
assert_eq!(catalog.model_count(), 1);
}
#[rstest]
fn snapshot_does_not_borrow_source() {
let mut source = ALPHA_FIXTURE.to_vec();
let catalog = Catalog::parse(&source, Provenance::default()).unwrap();
source.fill(b' ');
let entry = catalog.lookup("short").unwrap();
assert_eq!(entry.canonical_key, "Alpha");
assert_eq!(entry.entry.field("price"), Some(&json!(0)));
}
#[rstest]
#[case("Shared", "First")]
#[case("Second", "Second")]
#[case("shared", "Second")]
#[case("FIRST", "First")]
#[case("sHaReD", "Second")]
fn alias_collisions_and_case_fallback_follow_python_order(
#[case] lookup: &str,
#[case] expected: &str,
) {
let catalog = Catalog::parse(
br#"{
"First":{"aliases":["Shared","Second","first"],"value":1},
"Second":{"aliases":["Shared","sHaReD"],"value":2},
"SHARED":{"value":3}
}"#,
Provenance::default(),
)
.unwrap();
assert_eq!(catalog.lookup(lookup).unwrap().canonical_key, expected);
assert_eq!(catalog.alias_count(), 3);
assert!(
catalog
.alias_issues()
.contains(&AliasIssue::CanonicalCollision {
model: "First".into(),
alias: "Second".into(),
})
);
assert!(
catalog
.alias_issues()
.contains(&AliasIssue::AliasCollision {
model: "Second".into(),
alias: "Shared".into(),
})
);
}
#[derive(Debug)]
enum ValidationOutcome {
Ok,
Shrunk,
BelowMinimum,
InvalidRatio,
}
#[rstest]
#[case(
IntegrityLimits {
backup_model_count: 2,
min_model_count: 1,
min_backup_ratio: 0.5,
},
ValidationOutcome::Ok
)]
#[case(
IntegrityLimits {
backup_model_count: 3,
min_model_count: 1,
min_backup_ratio: 0.5,
},
ValidationOutcome::Shrunk
)]
#[case(
IntegrityLimits {
backup_model_count: 0,
min_model_count: 2,
min_backup_ratio: 0.5,
},
ValidationOutcome::BelowMinimum
)]
#[case(
IntegrityLimits {
backup_model_count: 0,
min_model_count: 0,
min_backup_ratio: f64::NAN,
},
ValidationOutcome::InvalidRatio
)]
fn integrity_uses_canonical_count_and_strict_shrink_boundary(
#[case] limits: IntegrityLimits,
#[case] expected: ValidationOutcome,
) {
let catalog = Catalog::parse(
br#"{"sample_spec":{},"fallback_generalizations":{"rules":[]},"a":{"aliases":["b","c"]}}"#,
Provenance::default(),
)
.unwrap();
let actual = catalog.validate(limits);
match expected {
ValidationOutcome::Ok => assert!(actual.is_ok()),
ValidationOutcome::Shrunk => {
assert!(matches!(actual, Err(Error::Shrunk { actual: 1, .. })))
}
ValidationOutcome::BelowMinimum => {
assert!(matches!(actual, Err(Error::BelowMinimum { actual: 1, .. })))
}
ValidationOutcome::InvalidRatio => assert!(matches!(actual, Err(Error::InvalidRatio))),
}
}
#[derive(Debug)]
enum MalformedOutcome {
Empty,
Json,
EntryNotObject,
}
#[rstest]
#[case::empty(b"{}", MalformedOutcome::Empty)]
#[case::invalid_json(b"{", MalformedOutcome::Json)]
#[case::entry_not_object(br#"{"a":1}"#, MalformedOutcome::EntryNotObject)]
#[case::fallback_rules_missing(
br#"{"fallback_generalizations":{},"a":{}}"#,
MalformedOutcome::Json
)]
fn malformed_input_and_aliases_have_typed_outcomes(
#[case] body: &[u8],
#[case] expected: MalformedOutcome,
) {
let actual = Catalog::parse(body, Provenance::default());
match expected {
MalformedOutcome::Empty => assert!(matches!(actual, Err(Error::Empty))),
MalformedOutcome::Json => assert!(matches!(actual, Err(Error::Json(_)))),
MalformedOutcome::EntryNotObject => {
assert!(matches!(actual, Err(Error::EntryNotObject { .. })))
}
}
}
#[rstest]
fn invalid_aliases_are_reported_not_fatal() {
let catalog = Catalog::parse(
br#"{"a":{"aliases":"bad"},"b":{"aliases":[9,"ok"]}}"#,
Provenance::default(),
)
.unwrap();
assert_eq!(
catalog.alias_issues(),
&[
AliasIssue::InvalidList { model: "a".into() },
AliasIssue::InvalidName { model: "b".into() },
]
);
assert_eq!(catalog.lookup("ok").unwrap().canonical_key, "b");
assert!(catalog.lookup("missing").is_none());
}
#[rstest]
fn parses_current_and_packaged_catalogs_without_pinning_counts(
current_catalog: Catalog,
backup_catalog: Catalog,
) {
assert!(current_catalog.model_count() > 0);
assert!(backup_catalog.model_count() > 0);
assert!(current_catalog.sample_spec().is_some());
assert!(backup_catalog.sample_spec().is_some());
assert!(
current_catalog
.validate(IntegrityLimits::python_defaults(
backup_catalog.model_count()
))
.is_ok()
);
for name in current_catalog.model_names() {
let entry = current_catalog.lookup(name).unwrap().entry;
assert_eq!(
entry.info().litellm_provider.is_some(),
entry.field("litellm_provider").is_some()
);
}
}

View file

@ -0,0 +1,121 @@
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use litellm_model_catalog::{
Catalog, FallbackGeneralizations, ModelInfo, Provenance, model_entry_json_schema,
};
use rstest::{fixture, rstest};
use serde_json::{Map, Value};
#[fixture]
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..")
}
fn json_eq(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
(Value::Array(left), Value::Array(right)) => {
left.len() == right.len() && left.iter().zip(right).all(|(a, b)| json_eq(a, b))
}
(Value::Object(left), Value::Object(right)) => {
left.len() == right.len()
&& left
.iter()
.all(|(key, value)| right.get(key).is_some_and(|other| json_eq(value, other)))
}
_ => left == right,
}
}
fn keys(value: &Map<String, Value>) -> BTreeSet<String> {
value.keys().cloned().collect()
}
fn symmetric_difference(left: &BTreeSet<String>, right: &BTreeSet<String>) -> BTreeSet<String> {
left.symmetric_difference(right).cloned().collect()
}
#[rstest]
#[case("model_prices_and_context_window.json")]
#[case("litellm/model_prices_and_context_window_backup.json")]
fn every_entry_round_trips_through_model_info(repo_root: PathBuf, #[case] filename: &str) {
let body = std::fs::read(repo_root.join(filename)).unwrap();
let document: IndexMap<String, Value> = serde_json::from_slice(&body).unwrap();
for (model_name, value) in document {
if matches!(
model_name.as_str(),
"sample_spec" | "fallback_generalizations"
) {
continue;
}
let object = value
.as_object()
.unwrap_or_else(|| panic!("{model_name} is not an object"));
let info: ModelInfo = serde_json::from_value(value.clone())
.unwrap_or_else(|error| panic!("{model_name} does not deserialize: {error}"));
let serialized = serde_json::to_value(info).unwrap();
let serialized_object = serialized
.as_object()
.unwrap_or_else(|| panic!("{model_name} did not serialize as an object"));
let mut expected = object.clone();
expected.remove("aliases");
let expected_keys = keys(&expected);
let serialized_keys = keys(serialized_object);
assert_eq!(
expected_keys,
serialized_keys,
"{model_name} key difference: {:?}",
symmetric_difference(&expected_keys, &serialized_keys)
);
assert!(
json_eq(&Value::Object(expected), &serialized),
"{model_name} changed during ModelInfo round-trip"
);
}
}
#[rstest]
fn fallback_generalizations_are_typed(repo_root: PathBuf) {
let body = std::fs::read(repo_root.join("model_prices_and_context_window.json")).unwrap();
let document: Map<String, Value> = serde_json::from_slice(&body).unwrap();
let Some(raw_rules) = document.get("fallback_generalizations") else {
return;
};
let _: FallbackGeneralizations = serde_json::from_value(raw_rules.clone()).unwrap();
let catalog = Catalog::parse(&body, Provenance::default()).unwrap();
assert!(
catalog
.fallback_rules()
.is_some_and(|rules| !rules.is_empty())
);
}
#[rstest]
fn generated_schema_properties_match_repo_schema(repo_root: PathBuf) {
let body =
std::fs::read(repo_root.join("model_prices_and_context_window.schema.json")).unwrap();
let document: Value = serde_json::from_slice(&body).unwrap();
let repo_entry_properties = document["$defs"]["modelEntry"]["properties"]
.as_object()
.unwrap();
let generated = serde_json::to_value(model_entry_json_schema()).unwrap();
let generated_properties = generated["properties"].as_object().unwrap();
let expected = keys(repo_entry_properties);
let actual = keys(generated_properties);
assert_eq!(
expected,
actual,
"modelEntry property difference: {:?}",
symmetric_difference(&expected, &actual)
);
let repo_root_properties = document["properties"].as_object().unwrap();
let actual_root: HashSet<String> = repo_root_properties.keys().cloned().collect();
let expected_root: HashSet<String> = ["sample_spec", "fallback_generalizations"]
.into_iter()
.map(str::to_owned)
.collect();
assert_eq!(actual_root, expected_root);
}

View file

@ -381,6 +381,7 @@ enable_model_config_credential_overrides: bool = False
enable_key_alias_format_validation: bool = (
False # opt-in validation of key_alias format on /key/generate and /key/update
)
key_alias_pattern: str | None = None
enable_gemini_default_thinking_level_low: bool = (
False # opt-in: force thinkingLevel low/minimal for Gemini 3 thinking param mapping
)
@ -1401,6 +1402,7 @@ from .exceptions import (
JSONSchemaValidationError,
LITELLM_EXCEPTION_TYPES,
MockException,
ModelNotMappedError as ModelNotMappedError,
)
from .budget_manager import BudgetManager
from .proxy.proxy_cli import run_server

View file

@ -781,35 +781,23 @@ class Cache:
Convert any embedding response into the standardized CachedEmbedding TypedDict format.
"""
try:
if isinstance(embedding_response, dict):
return {
"embedding": embedding_response.get("embedding"),
"index": embedding_response.get("index"),
"object": embedding_response.get("object"),
"model": model,
"prompt_tokens": prompt_tokens,
"prompt_tokens_details": prompt_tokens_details,
}
elif hasattr(embedding_response, "model_dump"):
data = embedding_response.model_dump()
return {
"embedding": data.get("embedding"),
"index": data.get("index"),
"object": data.get("object"),
"model": model,
"prompt_tokens": prompt_tokens,
"prompt_tokens_details": prompt_tokens_details,
}
else:
data = vars(embedding_response)
return {
"embedding": data.get("embedding"),
"index": data.get("index"),
"object": data.get("object"),
"model": model,
"prompt_tokens": prompt_tokens,
"prompt_tokens_details": prompt_tokens_details,
}
data: Final = (
embedding_response
if isinstance(embedding_response, dict)
else embedding_response.model_dump()
if hasattr(embedding_response, "model_dump")
else vars(embedding_response)
)
cached: Final[CachedEmbedding] = {
"embedding": data.get("embedding"),
"index": data.get("index"),
"object": data.get("object"),
"model": model,
"prompt_tokens": prompt_tokens,
"prompt_tokens_details": prompt_tokens_details,
"format_version": EMBEDDING_CACHE_FORMAT_VERSION,
}
return cached
except KeyError as e:
raise ValueError(f"Missing expected key in embedding response: {e}")
@ -925,6 +913,15 @@ class Cache:
if self.should_use_cache(**kwargs) is not True:
return
input_count: Final = len(kwargs["input"]) if isinstance(kwargs["input"], list) else 1
if len(result.data) != input_count:
verbose_logger.debug(
"LiteLLM Cache: skipping embedding cache write, %d inputs but %d embeddings in the response",
input_count,
len(result.data),
)
return
# set default ttl if not set
if self.ttl is not None:
kwargs["ttl"] = self.ttl

View file

@ -21,7 +21,7 @@ import time
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Mapping
from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, ValidationError
import litellm
from litellm._logging import print_verbose, verbose_logger
@ -34,7 +34,7 @@ from litellm.litellm_core_utils.llm_response_utils.response_metadata import (
from litellm.litellm_core_utils.logging_utils import (
_assemble_complete_response_from_streaming_chunks,
)
from litellm.types.caching import CachedEmbedding
from litellm.types.caching import EMBEDDING_CACHE_FORMAT_VERSION, CachedEmbedding
from litellm.types.integrations.custom_logger import converted_stream_requested
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.rerank import RerankResponse
@ -77,6 +77,7 @@ class CachingHandlerResponse(BaseModel):
cached_result: object | None = None
final_embedding_cached_response: EmbeddingResponse | None = None
embedding_all_elements_cache_hit: bool = False # this is set to True when all elements in the list have a cache hit in the embedding cache, if true return the final_embedding_cached_response no need to make an API call
embedding_uncached_input: list[str | list[int]] | None = None
in_memory_cache_obj: Final = InMemoryCache()
@ -168,6 +169,37 @@ def _request_cache_key(request_kwargs: Mapping[str, Any]) -> str | None:
return request_kwargs.get("cache_key", None)
class _CachedEmbeddingRecord(BaseModel):
model_config = ConfigDict(frozen=True)
embedding: list[float] | str | None
index: int | None
object: str | None
model: str | None
prompt_tokens: int | None
prompt_tokens_details: dict | None
format_version: int
def _current_format_embedding_entry(entry: object) -> CachedEmbedding | None:
try:
record: Final = _CachedEmbeddingRecord.model_validate(entry)
except ValidationError:
return None
if record.format_version != EMBEDDING_CACHE_FORMAT_VERSION:
return None
cached: Final[CachedEmbedding] = {
"embedding": record.embedding,
"index": record.index,
"object": record.object,
"model": record.model,
"prompt_tokens": record.prompt_tokens,
"prompt_tokens_details": record.prompt_tokens_details,
"format_version": record.format_version,
}
return cached
class LLMCachingHandler:
def __init__(
self,
@ -320,6 +352,7 @@ class LLMCachingHandler:
return CachingHandlerResponse(
final_embedding_cached_response=final_embedding_cached_response,
embedding_all_elements_cache_hit=embedding_all_elements_cache_hit,
embedding_uncached_input=self.handle_kwargs_input_list_or_str(kwargs),
)
verbose_logger.debug("CACHE RESULT: %s", cached_result)
@ -657,32 +690,30 @@ class LLMCachingHandler:
if _caching_handler_response.final_embedding_cached_response is None:
return embedding_response
idx = 0
final_data_list: Final = []
for item in _caching_handler_response.final_embedding_cached_response.data:
if item is None and embedding_response.data is not None:
final_data_list.append(embedding_response.data[idx])
idx += 1
else:
final_data_list.append(item)
_caching_handler_response.final_embedding_cached_response.data = final_data_list
_caching_handler_response.final_embedding_cached_response._hidden_params["cache_hit"] = True
_caching_handler_response.final_embedding_cached_response._response_ms = (
end_time - start_time
).total_seconds() * 1000
## USAGE
if (
_caching_handler_response.final_embedding_cached_response.usage is not None
and embedding_response.usage is not None
):
_caching_handler_response.final_embedding_cached_response.usage = self.combine_usage(
usage1=_caching_handler_response.final_embedding_cached_response.usage,
usage2=embedding_response.usage,
)
return _caching_handler_response.final_embedding_cached_response
cached: Final = _caching_handler_response.final_embedding_cached_response
fresh_items: Final = iter(embedding_response.data or ())
merged_usage: Final = (
self.combine_usage(usage1=cached.usage, usage2=embedding_response.usage)
if cached.usage is not None and embedding_response.usage is not None
else cached.usage
)
merged: Final = EmbeddingResponse(
model=cached.model,
data=[ # mutable-ok: EmbeddingResponse.data is a pydantic list field
item
if item is not None
else Embedding(embedding=next(fresh_items)["embedding"], index=position, object="embedding")
for position, item in enumerate(cached.data)
],
usage=merged_usage,
hidden_params={ # mutable-ok: EmbeddingResponse._hidden_params is a mutable dict field
**cached._hidden_params,
"cache_hit": True,
},
_response_headers=cached._response_headers,
)
merged._response_ms = (end_time - start_time).total_seconds() * 1000
return merged
def _async_log_cache_hit_on_callbacks(
self,
@ -770,7 +801,7 @@ class LLMCachingHandler:
dynamic_cache_object=self.dual_cache,
)
)
cached_result = await asyncio.gather(*tasks)
cached_result = [_current_format_embedding_entry(entry) for entry in await asyncio.gather(*tasks)]
## check if cached result is None ##
if cached_result is not None and isinstance(cached_result, list):
# set cached_result to None if all elements are None

View file

@ -991,6 +991,10 @@ LITELLM_EXCEPTION_TYPES: Final = [
]
class ModelNotMappedError(Exception):
pass
class BudgetExceededError(Exception):
def __init__(
self,

View file

@ -240,7 +240,7 @@ class OpenTelemetryV2(CustomLogger):
provider: Final = resolve_logger_provider(self.config, logger_provider)
if provider is None:
return None
return GenAIEventRecorder(get_event_logger(provider, LITELLM_TRACER_NAME))
return GenAIEventRecorder(get_event_logger(provider, LITELLM_TRACER_NAME), provider.resource)
# ====================================================================== #
# Proxy global registration

View file

@ -245,6 +245,7 @@ class GenAIEvent:
details, unlike the deprecated ``error.message`` span attribute.
"""
NAME_KEY: Final = "event.name"
OPERATION_EXCEPTION: Final = "gen_ai.client.operation.exception"

View file

@ -9,18 +9,28 @@ emitting that event; the exporter pipeline it rides is built in
"""
from dataclasses import dataclass
from time import time_ns
from typing import Final
from opentelemetry._events import Event, EventLogger
from opentelemetry._logs import Logger, LogRecord
from opentelemetry._logs.severity import SeverityNumber
from opentelemetry.sdk.resources import Resource
from opentelemetry.trace import SpanContext
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
try:
from opentelemetry.sdk._logs import LogRecord as _SDKLogRecord
except ImportError:
_SDKLogRecord = None
SDK_LOG_RECORD: Final[type[LogRecord] | None] = _SDKLogRecord
@dataclass(frozen=True, slots=True)
class GenAIEventRecorder:
event_logger: EventLogger
event_logger: Logger
resource: Resource | None = None
def record_operation_exception(
self,
@ -30,24 +40,36 @@ class GenAIEventRecorder:
stack_trace: str | None,
timestamp_ns: int | None,
) -> None:
# ``exception.type`` and ``exception.message`` are the semconv-required
# pair and always ride the event; only the recommended stacktrace is
# conditional on the payload carrying one.
stacktrace: Final = ((ExceptionEvent.STACKTRACE, stack_trace),) if stack_trace else ()
self.event_logger.emit(
Event(
name=GenAIEvent.OPERATION_EXCEPTION,
timestamp=timestamp_ns,
attributes: Final = dict(
(
(GenAIEvent.NAME_KEY, GenAIEvent.OPERATION_EXCEPTION),
(ExceptionEvent.TYPE, error_type),
(ExceptionEvent.MESSAGE, message),
*stacktrace,
)
)
record: Final[LogRecord] = (
SDK_LOG_RECORD(
timestamp=timestamp_ns or time_ns(),
trace_id=span_context.trace_id,
span_id=span_context.span_id,
trace_flags=span_context.trace_flags,
severity_number=SeverityNumber.WARN,
attributes=dict(
(
(ExceptionEvent.TYPE, error_type),
(ExceptionEvent.MESSAGE, message),
*stacktrace,
)
),
body=message,
attributes=attributes,
resource=self.resource, # pyright: ignore[reportCallIssue] # SDK-only kwarg absent from the API LogRecord signature on the pin
)
if SDK_LOG_RECORD is not None
else LogRecord(
timestamp=timestamp_ns or time_ns(),
trace_id=span_context.trace_id,
span_id=span_context.span_id,
trace_flags=span_context.trace_flags,
severity_number=SeverityNumber.WARN,
body=message,
attributes=attributes,
event_name=GenAIEvent.OPERATION_EXCEPTION, # pyright: ignore[reportCallIssue] # kwarg exists only on OTel 1.38+, absent from the pinned API signature
)
)
self.event_logger.emit(record)

View file

@ -9,11 +9,9 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal
from opentelemetry import _logs, baggage, metrics, trace
from opentelemetry._events import EventLogger
from opentelemetry._logs import LoggerProvider, NoOpLoggerProvider
from opentelemetry._logs import Logger, LoggerProvider, NoOpLoggerProvider
from opentelemetry.context import Context
from opentelemetry.metrics import MeterProvider, NoOpMeterProvider
from opentelemetry.sdk._events import EventLoggerProvider
from opentelemetry.sdk._logs import LoggerProvider as SDKLoggerProvider
from opentelemetry.sdk._logs.export import (
BatchLogRecordProcessor,
@ -1042,8 +1040,8 @@ def resolve_logger_provider(
return provider
def get_event_logger(provider: SDKLoggerProvider, name: str = "litellm") -> EventLogger:
return EventLoggerProvider(logger_provider=provider).get_event_logger(name, litellm_version)
def get_event_logger(provider: SDKLoggerProvider, name: str = "litellm") -> Logger:
return provider.get_logger(name, litellm_version)
def build_meter_provider(

View file

@ -0,0 +1,195 @@
"""
Map any exception litellm logs to one stable ``normalized_error`` code so dashboards can cluster
failures without parsing free-text messages that embed team names, token counts, model names, etc.
"""
import re
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final, Protocol, runtime_checkable
from litellm.exceptions import (
APIConnectionError,
AuthenticationError,
BadGatewayError,
BadRequestError,
BlockedPiiEntityError,
BudgetExceededError,
ContentPolicyViolationError,
ContextWindowExceededError,
GuardrailRaisedException,
InternalServerError,
MidStreamFallbackError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
RateLimitType,
ServiceUnavailableError,
Timeout,
UnprocessableEntityError,
UnsupportedParamsError,
)
RATE_LIMIT_EXCEEDED: Final = "429_RATE_LIMIT_EXCEEDED"
BUDGET_EXCEEDED: Final = "429_BUDGET_EXCEEDED"
NO_HEALTHY_DEPLOYMENTS: Final = "429_NO_HEALTHY_DEPLOYMENTS"
AUTHENTICATION_FAILED: Final = "401_AUTHENTICATION_FAILED"
MODEL_ACCESS_DENIED: Final = "403_MODEL_ACCESS_DENIED"
PERMISSION_DENIED: Final = "403_PERMISSION_DENIED"
MISSING_REQUIRED_PARAMETER: Final = "400_MISSING_REQUIRED_PARAMETER"
INVALID_PARAMETER_VALUE: Final = "400_INVALID_PARAMETER_VALUE"
CONTEXT_WINDOW_EXCEEDED: Final = "400_CONTEXT_WINDOW_EXCEEDED"
CONTENT_POLICY_VIOLATION: Final = "400_CONTENT_POLICY_VIOLATION"
INVALID_REQUEST: Final = "400_INVALID_REQUEST"
RESOURCE_NOT_FOUND: Final = "404_RESOURCE_NOT_FOUND"
UPSTREAM_TIMEOUT: Final = "408_UPSTREAM_TIMEOUT"
PROVIDER_CONNECTION_ERROR: Final = "500_PROVIDER_CONNECTION_ERROR"
PROVIDER_OVERLOADED: Final = "503_PROVIDER_OVERLOADED"
PROVIDER_INTERNAL_ERROR: Final = "500_PROVIDER_INTERNAL_ERROR"
ROUTER_NO_FALLBACK: Final = "500_ROUTER_NO_FALLBACK"
ROUTER_FALLBACK_FAILURE: Final = "500_ROUTER_FALLBACK_FAILURE"
UPSTREAM_PASSTHROUGH: Final = "500_UPSTREAM_PASSTHROUGH"
UNSUPPORTED_OPERATION: Final = "500_UNSUPPORTED_OPERATION"
INTERNAL_STATE_ERROR: Final = "500_INTERNAL_STATE_ERROR"
UNCLASSIFIED: Final = "UNCLASSIFIED"
@runtime_checkable
class _HasProxyErrorType(Protocol):
type: str
_MESSAGE_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
(
re.compile(r"budget has been exceeded|max budget|exceeded.*budget|crossed budget", re.IGNORECASE),
BUDGET_EXCEEDED,
),
(re.compile(r"no healthy deployments?|no deployments available", re.IGNORECASE), NO_HEALTHY_DEPLOYMENTS),
(re.compile(r"not allowed to access model due to tags configuration", re.IGNORECASE), MODEL_ACCESS_DENIED),
(re.compile(r"upstream passthrough request failed", re.IGNORECASE), UPSTREAM_PASSTHROUGH),
(re.compile(r"is not supported for provider|not implemented", re.IGNORECASE), UNSUPPORTED_OPERATION),
(
re.compile(r"context window|context length|(prompt|input) is too long|tokens? ?> ?\d+ ?maximum", re.IGNORECASE),
CONTEXT_WINDOW_EXCEEDED,
),
(re.compile(r"missing required parameter|field required", re.IGNORECASE), MISSING_REQUIRED_PARAMETER),
(re.compile(r"overloaded|unable to process your request", re.IGNORECASE), PROVIDER_OVERLOADED),
(
re.compile(
r"connection error|APIConnectionError|TransferEncodingError|payload is not completed|connection reset"
r"|peer closed connection|incomplete chunked read",
re.IGNORECASE,
),
PROVIDER_CONNECTION_ERROR,
),
(re.compile(r"timed? ?out", re.IGNORECASE), UPSTREAM_TIMEOUT),
)
_ROUTER_WRAPPER_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
(re.compile(r"no fallback model group found", re.IGNORECASE), ROUTER_NO_FALLBACK),
(re.compile(r"error doing the fallback|MidStreamFallbackError", re.IGNORECASE), ROUTER_FALLBACK_FAILURE),
)
_PROXY_ERROR_TYPE_MAP: Final[Mapping[str, str]] = MappingProxyType(
{
"budget_exceeded": BUDGET_EXCEEDED,
"auth_error": AUTHENTICATION_FAILED,
"expired_key": AUTHENTICATION_FAILED,
"token_not_found_in_db": AUTHENTICATION_FAILED,
"auth_provider_unavailable": AUTHENTICATION_FAILED,
"key_model_access_denied": MODEL_ACCESS_DENIED,
"team_model_access_denied": MODEL_ACCESS_DENIED,
"user_model_access_denied": MODEL_ACCESS_DENIED,
"org_model_access_denied": MODEL_ACCESS_DENIED,
"project_model_access_denied": MODEL_ACCESS_DENIED,
"agent_model_access_denied": MODEL_ACCESS_DENIED,
"key_vector_store_access_denied": PERMISSION_DENIED,
"team_vector_store_access_denied": PERMISSION_DENIED,
"org_vector_store_access_denied": PERMISSION_DENIED,
"tool_access_denied": PERMISSION_DENIED,
"team_member_permission_error": PERMISSION_DENIED,
"not_found_error": RESOURCE_NOT_FOUND,
}
)
_STATUS_CODE_MAP: Final[Mapping[str, str]] = MappingProxyType(
{
"400": INVALID_REQUEST,
"401": AUTHENTICATION_FAILED,
"403": PERMISSION_DENIED,
"404": RESOURCE_NOT_FOUND,
"408": UPSTREAM_TIMEOUT,
"422": INVALID_PARAMETER_VALUE,
"429": RATE_LIMIT_EXCEEDED,
"500": PROVIDER_INTERNAL_ERROR,
"502": PROVIDER_INTERNAL_ERROR,
"503": PROVIDER_OVERLOADED,
"504": UPSTREAM_TIMEOUT,
}
)
_INTERNAL_STATE_EXCEPTIONS: Final[tuple[type[BaseException], ...]] = (
TypeError,
KeyError,
AttributeError,
IndexError,
RuntimeError,
AssertionError,
ZeroDivisionError,
)
_CLASS_CODE_TABLE: Final[tuple[tuple[tuple[type[BaseException], ...], str], ...]] = (
((AuthenticationError,), AUTHENTICATION_FAILED),
((PermissionDeniedError,), PERMISSION_DENIED),
((ContextWindowExceededError,), CONTEXT_WINDOW_EXCEEDED),
((ContentPolicyViolationError, GuardrailRaisedException, BlockedPiiEntityError), CONTENT_POLICY_VIOLATION),
((UnsupportedParamsError,), INVALID_PARAMETER_VALUE),
((NotFoundError,), RESOURCE_NOT_FOUND),
((Timeout,), UPSTREAM_TIMEOUT),
((MidStreamFallbackError,), ROUTER_FALLBACK_FAILURE),
((APIConnectionError,), PROVIDER_CONNECTION_ERROR),
((ServiceUnavailableError,), PROVIDER_OVERLOADED),
((InternalServerError, BadGatewayError), PROVIDER_INTERNAL_ERROR),
((BadRequestError, UnprocessableEntityError), INVALID_REQUEST),
((NotImplementedError,), UNSUPPORTED_OPERATION),
)
def _classify_by_message(message: str, patterns: tuple[tuple[re.Pattern[str], str], ...]) -> str | None:
return next((code for pattern, code in patterns if pattern.search(message)), None)
def _classify_by_class(exc: Exception) -> str | None:
if isinstance(exc, BudgetExceededError):
return BUDGET_EXCEEDED
if isinstance(exc, RateLimitError):
return BUDGET_EXCEEDED if exc.rate_limit_type == RateLimitType.BUDGET.value else RATE_LIMIT_EXCEEDED
for exc_types, code in _CLASS_CODE_TABLE:
if isinstance(exc, exc_types):
return code
if isinstance(exc, _INTERNAL_STATE_EXCEPTIONS):
return INTERNAL_STATE_ERROR
return None
def normalize_error(exc: Exception | None, status_code: str, message: str) -> str | None:
"""
Return a stable cluster key for ``exc``. ``status_code`` and ``message`` are the values
``get_error_information`` already extracted, so the same exception always yields the same code.
"""
if exc is None:
return None
proxy_type: Final = exc.type if isinstance(exc, _HasProxyErrorType) else None
by_proxy_type: Final = _PROXY_ERROR_TYPE_MAP.get(proxy_type) if isinstance(proxy_type, str) else None
if by_proxy_type is not None:
return by_proxy_type
by_message: Final = _classify_by_message(message, _MESSAGE_PATTERNS)
if by_message is not None:
return by_message
by_class: Final = _classify_by_class(exc)
if by_class is not None:
return by_class
by_router_wrapper: Final = _classify_by_message(message, _ROUTER_WRAPPER_PATTERNS)
if by_router_wrapper is not None:
return by_router_wrapper
return _STATUS_CODE_MAP.get(status_code, UNCLASSIFIED)

View file

@ -76,6 +76,7 @@ from litellm.litellm_core_utils.core_helpers import (
reconstruct_model_name,
set_response_cost_in_hidden_params,
)
from litellm.litellm_core_utils.error_normalization import normalize_error
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
from litellm.litellm_core_utils.internal_call_metadata import (
MODEL_ACCESS_GROUP_METADATA_KEY,
@ -6100,6 +6101,7 @@ class StandardLoggingPayloadSetup:
error_budget_entity_id=budget_error.entity_id if budget_error else None,
error_budget_limit=budget_error.max_budget if budget_error else None,
error_budget_spend=budget_error.current_cost if budget_error else None,
normalized_error=normalize_error(original_exception, error_status, error_message),
)
@staticmethod

View file

@ -1657,7 +1657,7 @@ def get_file_ids_from_messages(messages: list[AllMessageValues]) -> list[str]:
if isinstance(content, str):
continue
for c in content:
if c["type"] == "file":
if isinstance(c, dict) and c["type"] == "file":
file_object = cast(ChatCompletionFileObject, c)
file_object_file_field = file_object.get("file")
if not isinstance(file_object_file_field, dict):

View file

@ -314,7 +314,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
_message_content = message.get("content")
if _message_content is not None and isinstance(_message_content, list):
for content in _message_content:
if "cache_control" in content:
if isinstance(content, dict) and "cache_control" in content:
return True
return False
@ -359,7 +359,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
for message in messages:
if "content" in message and message["content"] is not None and isinstance(message["content"], list):
for content in message["content"]:
if "type" in content and content["type"] != "text":
if isinstance(content, dict) and "type" in content and content["type"] != "text":
return True
return False

View file

@ -1773,6 +1773,46 @@
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"global.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1811,6 +1851,46 @@
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"global.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"us.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1849,6 +1929,46 @@
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"us.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"eu.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1886,6 +2006,46 @@
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"eu.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"au.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1923,6 +2083,46 @@
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"au.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"jp.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1960,6 +2160,46 @@
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"jp.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"anthropic.claude-opus-4-8": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -3287,6 +3527,41 @@
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"azure_ai/claude-opus-5-5": {
"supports_mid_conversation_system": true,
"input_cost_per_token": 4e-06,
"output_cost_per_token": 2e-05,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"supports_adaptive_thinking": true,
"thinking_always_on": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_forced_tool_use": false,
"supports_function_calling": true,
"supports_native_structured_output": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"azure_ai/claude-opus-4-8": {
"deprecation_date": "2027-09-01",
"supports_mid_conversation_system": true,
@ -44379,6 +44654,41 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"us-gov.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 6e-06,
"cache_creation_input_token_cost_above_1hr": 9.6e-06,
"cache_read_input_token_cost": 2.4e-07,
"input_cost_per_token": 4.8e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.4e-05,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_mid_conversation_system": true,
"supports_native_structured_output": false,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"us-gov.anthropic.claude-fable-5-1": {
"cache_creation_input_token_cost": 1.5e-05,
"cache_creation_input_token_cost_above_1hr": 2.4e-05,
@ -46741,6 +47051,78 @@
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5-5": {
"regional_endpoint_uplift_multiplier": 1.1,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"thinking_always_on": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_forced_tool_use": false,
"supports_function_calling": true,
"supports_native_structured_output": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5-5@default": {
"regional_endpoint_uplift_multiplier": 1.1,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"thinking_always_on": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_forced_tool_use": false,
"supports_function_calling": true,
"supports_native_structured_output": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-4-8": {
"deprecation_date": "2027-05-28",
"regional_endpoint_uplift_multiplier": 1.1,
@ -60807,6 +61189,41 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"bedrock/us-gov-west-1/anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 6e-06,
"cache_creation_input_token_cost_above_1hr": 9.6e-06,
"cache_read_input_token_cost": 2.4e-07,
"input_cost_per_token": 4.8e-06,
"litellm_provider": "bedrock",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.4e-05,
"prompt_cache_min_tokens": 512,
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_mid_conversation_system": true,
"supports_native_structured_output": false,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"bedrock/us-gov-west-1/anthropic.claude-fable-5-1": {
"cache_creation_input_token_cost": 1.5e-05,
"cache_creation_input_token_cost_above_1hr": 2.4e-05,
@ -61013,6 +61430,41 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"bedrock/us-gov-east-1/anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 6e-06,
"cache_creation_input_token_cost_above_1hr": 9.6e-06,
"cache_read_input_token_cost": 2.4e-07,
"input_cost_per_token": 4.8e-06,
"litellm_provider": "bedrock",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.4e-05,
"prompt_cache_min_tokens": 512,
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_mid_conversation_system": true,
"supports_native_structured_output": false,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"bedrock/us-gov-east-1/anthropic.claude-fable-5-1": {
"cache_creation_input_token_cost": 1.5e-05,
"cache_creation_input_token_cost_above_1hr": 2.4e-05,
@ -71883,5 +72335,68 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"xai/grok-code-fast": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_200k_tokens": 2e-06,
"litellm_provider": "xai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"output_cost_per_token_above_200k_tokens": 4e-06,
"source": "https://api.x.ai/v1/language-models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"xai/grok-code-fast-1": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_200k_tokens": 2e-06,
"litellm_provider": "xai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"output_cost_per_token_above_200k_tokens": 4e-06,
"source": "https://api.x.ai/v1/language-models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"xai/grok-code-fast-1-0825": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_200k_tokens": 2e-06,
"litellm_provider": "xai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"output_cost_per_token_above_200k_tokens": 4e-06,
"source": "https://api.x.ai/v1/language-models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
}
}

View file

@ -2741,6 +2741,10 @@ async def _resolve_team_org_filter(
async def ui_view_users(
user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"),
user_email: str | None = fastapi.Query(default=None, description="User email in the request parameters"),
search: str | None = fastapi.Query(
default=None,
description="Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive).",
),
team_id: str | None = fastapi.Query(
default=None,
description="Team ID — used when a team admin searches for users to add to their team",
@ -2750,7 +2754,7 @@ async def ui_view_users(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Filter users based on partial match of user_id or email with pagination.
Filter users based on partial match of user_id or email, or combined ``search``, with pagination.
Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag
(stored in the ``litellm_uisettings`` table):
@ -2802,9 +2806,15 @@ async def ui_view_users(
if org_filter_ids is not None:
where_conditions["organization_memberships"] = {"some": {"organization_id": {"in": org_filter_ids}}}
where: Final[Mapping[str, object]] = { # mutable-ok: prisma serializes `where`, keep it a plain dict
key: value
for key, value in (*where_conditions.items(), *_user_search_where(search).items())
if value is not None
}
# Query users with pagination and filters
users: Final = await _user_table(prisma_client).find_many(
where=where_conditions,
where=where,
skip=skip,
take=page_size,
order={"created_at": "desc"},

View file

@ -7590,25 +7590,47 @@ async def test_key_logging(
_KEY_ALIAS_PATTERN: Final = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-/\.@]{0,253}[a-zA-Z0-9]$")
_KEY_ALIAS_PATTERN_MESSAGE: Final = (
"Invalid key_alias format. Must be 2-255 characters, start/end with alphanumeric, and only contain a-zA-Z0-9_-/.@."
)
_KEY_ALIAS_MAX_LENGTH: Final = 255
def parse_key_alias_pattern(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError(
f"Invalid regex set for litellm_settings.key_alias_pattern - value={value!r}: must be a string"
)
try:
re.compile(value)
except re.error as e:
raise ValueError(f"Invalid regex set for litellm_settings.key_alias_pattern - value={value}: {e}") from e
return value
def _key_alias_rule() -> tuple[re.Pattern[str], str] | None:
if litellm.key_alias_pattern is not None:
return (
re.compile(litellm.key_alias_pattern),
f"Invalid key_alias format. Must be at most {_KEY_ALIAS_MAX_LENGTH} characters and match the configured"
f" key_alias_pattern: {litellm.key_alias_pattern}",
)
if litellm.enable_key_alias_format_validation:
return (_KEY_ALIAS_PATTERN, _KEY_ALIAS_PATTERN_MESSAGE)
return None
def _validate_key_alias_format(key_alias: str | None) -> None:
"""
Validate the format of the key_alias.
A baseline validation always runs, regardless of
``litellm.enable_key_alias_format_validation``.
The remaining charset/length rules are gated behind
``litellm.enable_key_alias_format_validation`` (default **False**). When disabled,
only the baseline validation above is performed, so existing workflows are not
broken.
Rules (when enabled):
- None is OK (no alias).
- Otherwise must be 2–255 chars
- start/end with alphanumeric
- only allow a-zA-Z0-9_-/.@
Path traversal and control characters are always rejected. The alias then has to
stay within ``_KEY_ALIAS_MAX_LENGTH`` and fully match ``litellm.key_alias_pattern``
when one is configured, else the built-in pattern when
``litellm.enable_key_alias_format_validation`` is on, else nothing more is checked
so existing workflows are not broken.
"""
if key_alias is None:
return
@ -7623,12 +7645,14 @@ def _validate_key_alias_format(key_alias: str | None) -> None:
code=400,
)
if not litellm.enable_key_alias_format_validation:
rule: Final = _key_alias_rule()
if rule is None:
return
if not _KEY_ALIAS_PATTERN.match(key_alias):
pattern, message = rule
if len(key_alias) > _KEY_ALIAS_MAX_LENGTH or pattern.fullmatch(key_alias) is None:
raise ProxyException(
message="Invalid key_alias format. Must be 2-255 characters, start/end with alphanumeric, and only contain a-zA-Z0-9_-/.@.",
message=message,
type=ProxyErrorTypes.bad_request_error,
param="key_alias",
code=400,

View file

@ -597,6 +597,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
delete_verification_tokens,
duration_in_seconds,
generate_key_helper_fn,
parse_key_alias_pattern,
)
from litellm.proxy.management_endpoints.key_management_endpoints import (
router as key_management_router,
@ -6267,6 +6268,8 @@ class ProxyConfig:
litellm.upperbound_key_generate_params = LiteLLM_UpperboundKeyGenerateParams(**value)
else:
raise Exception(f"Invalid value set for upperbound_key_generate_params - value={value}")
elif key == "key_alias_pattern":
litellm.key_alias_pattern = parse_key_alias_pattern(value)
elif key == "json_logs" and value is True:
litellm.json_logs = True
litellm._turn_on_json()

View file

@ -2323,6 +2323,13 @@ async def calculate_spend(request: SpendCalculateRequest):
param=getattr(e, "param", "None"),
code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST),
)
if isinstance(e, litellm.exceptions.ModelNotMappedError):
raise ProxyException(
message=str(e),
type="invalid_request_error",
param="model",
code=status.HTTP_400_BAD_REQUEST,
)
error_msg: Final = f"{e}"
raise ProxyException(
message=getattr(e, "message", error_msg),

View file

@ -3,7 +3,7 @@ from enum import Enum
from typing import Any, Final, Literal, Optional, Union
from pydantic import BaseModel
from typing_extensions import TypedDict
from typing_extensions import ReadOnly, TypedDict
class LiteLLMCacheType(str, Enum):
@ -137,12 +137,16 @@ class HealthCheckCacheParams(BaseModel):
redis_version: str | int | float | None = None
EMBEDDING_CACHE_FORMAT_VERSION: Final = 2
class CachedEmbedding(TypedDict):
"""Type definition for cached embedding objects"""
embedding: list[float] | None
index: int | None
object: str | None
model: str | None
prompt_tokens: int | None
prompt_tokens_details: dict | None
embedding: ReadOnly[list[float] | str | None]
index: ReadOnly[int | None]
object: ReadOnly[str | None]
model: ReadOnly[str | None]
prompt_tokens: ReadOnly[int | None]
prompt_tokens_details: ReadOnly[dict | None]
format_version: ReadOnly[int]

View file

@ -3256,6 +3256,7 @@ class StandardLoggingPayloadErrorInformation(TypedDict, total=False):
error_budget_entity_id: str | None
error_budget_limit: float | None
error_budget_spend: float | None
normalized_error: ReadOnly[str | None]
class GuardrailMode(TypedDict, total=False):

View file

@ -472,6 +472,7 @@ from .exceptions import (
BudgetExceededError,
ContentPolicyViolationError,
ContextWindowExceededError,
ModelNotMappedError,
NotFoundError,
OpenAIError,
PermissionDeniedError,
@ -2015,13 +2016,19 @@ def client(original_function):
print_verbose(f"Error while checking max token limit: {e}")
# MODEL CALL
call_kwargs: Final = (
{**kwargs, "input": _caching_handler_response.embedding_uncached_input}
if _caching_handler_response is not None
and _caching_handler_response.embedding_uncached_input is not None
else kwargs
)
try:
result = await original_function(*args, **kwargs)
result = await original_function(*args, **call_kwargs)
except Exception as deployment_error:
_deployment_call_end_time = datetime.datetime.now() # noqa: DTZ005 # matches the naive datetimes this whole function already times start_time/end_time with
try:
await async_post_call_failure_deployment_hook(
request_data=kwargs,
request_data=call_kwargs,
exception=deployment_error,
call_type=call_type,
)
@ -2062,7 +2069,7 @@ def client(original_function):
post_call_processing(
original_response=result,
model=model,
optional_params=kwargs,
optional_params=call_kwargs,
original_function=original_function,
rules_obj=rules_obj,
)
@ -2070,7 +2077,7 @@ def client(original_function):
_call_type_enum: Final = _CALL_TYPE_ENUM_MAP.get(call_type)
if _call_type_enum is not None:
result = await async_post_call_success_deployment_hook(
request_data=kwargs,
request_data=call_kwargs,
response=result,
call_type=_call_type_enum,
)
@ -2079,7 +2086,7 @@ def client(original_function):
await _llm_caching_handler.async_set_cache(
result=result,
original_function=original_function,
kwargs=kwargs,
kwargs=call_kwargs,
args=args,
)
@ -5824,6 +5831,13 @@ def _is_potential_model_name_in_model_cost(
_ABOVE_THRESHOLD_COST_KEY: Final = ABOVE_THRESHOLD_COST_KEY_PATTERN
def _model_not_mapped_message(model: str, custom_llm_provider: str | None) -> str:
return (
f"This model isn't mapped yet. model={model}, custom_llm_provider={custom_llm_provider}. "
"Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json."
)
def _get_model_info_helper(
model: str,
custom_llm_provider: str | None = None,
@ -6006,9 +6020,7 @@ def _get_model_info_helper(
key, _model_info = generalization
if _model_info is None or key is None:
raise ValueError(
"This model isn't mapped yet. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json"
)
raise ModelNotMappedError(_model_not_mapped_message(model, custom_llm_provider))
_input_cost_per_token: float | None = _model_info.get("input_cost_per_token")
if _input_cost_per_token is None:
# default value to 0, be noisy about this
@ -6243,11 +6255,11 @@ def _get_model_info_helper(
if cost_key not in returned_model_info and _ABOVE_THRESHOLD_COST_KEY.search(cost_key) is not None:
returned_model_info[cost_key] = cost_value
return returned_model_info
except ModelNotMappedError:
raise
except Exception as e:
verbose_logger.debug("Error getting model info: %s", e)
raise Exception(
f"This model isn't mapped yet. model={model}, custom_llm_provider={custom_llm_provider}. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json."
)
raise Exception(_model_not_mapped_message(model, custom_llm_provider))
def _build_model_info(

View file

@ -1773,6 +1773,46 @@
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"global.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1811,6 +1851,46 @@
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"global.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"us.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1849,6 +1929,46 @@
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"us.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"eu.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1886,6 +2006,46 @@
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"eu.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"au.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1923,6 +2083,46 @@
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"au.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"jp.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -1960,6 +2160,46 @@
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"jp.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5.5e-06,
"cache_creation_input_token_cost_above_1hr": 8.8e-06,
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 4.4e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": false,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"anthropic.claude-opus-4-8": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
@ -3287,6 +3527,41 @@
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"azure_ai/claude-opus-5-5": {
"supports_mid_conversation_system": true,
"input_cost_per_token": 4e-06,
"output_cost_per_token": 2e-05,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"supports_adaptive_thinking": true,
"thinking_always_on": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_forced_tool_use": false,
"supports_function_calling": true,
"supports_native_structured_output": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"azure_ai/claude-opus-4-8": {
"deprecation_date": "2027-09-01",
"supports_mid_conversation_system": true,
@ -44379,6 +44654,41 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"us-gov.anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 6e-06,
"cache_creation_input_token_cost_above_1hr": 9.6e-06,
"cache_read_input_token_cost": 2.4e-07,
"input_cost_per_token": 4.8e-06,
"litellm_provider": "bedrock_converse",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.4e-05,
"prompt_cache_min_tokens": 512,
"source": "https://aws.amazon.com/bedrock/pricing/",
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_mid_conversation_system": true,
"supports_native_structured_output": false,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"thinking_always_on": true,
"supports_forced_tool_use": false
},
"us-gov.anthropic.claude-fable-5-1": {
"cache_creation_input_token_cost": 1.5e-05,
"cache_creation_input_token_cost_above_1hr": 2.4e-05,
@ -46741,6 +47051,78 @@
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5-5": {
"regional_endpoint_uplift_multiplier": 1.1,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"thinking_always_on": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_forced_tool_use": false,
"supports_function_calling": true,
"supports_native_structured_output": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5-5@default": {
"regional_endpoint_uplift_multiplier": 1.1,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 5e-06,
"cache_creation_input_token_cost_above_1hr": 8e-06,
"cache_read_input_token_cost": 2e-07,
"input_cost_per_token": 4e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"thinking_always_on": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_forced_tool_use": false,
"supports_function_calling": true,
"supports_native_structured_output": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-4-8": {
"deprecation_date": "2027-05-28",
"regional_endpoint_uplift_multiplier": 1.1,
@ -60807,6 +61189,41 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"bedrock/us-gov-west-1/anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 6e-06,
"cache_creation_input_token_cost_above_1hr": 9.6e-06,
"cache_read_input_token_cost": 2.4e-07,
"input_cost_per_token": 4.8e-06,
"litellm_provider": "bedrock",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.4e-05,
"prompt_cache_min_tokens": 512,
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_mid_conversation_system": true,
"supports_native_structured_output": false,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"bedrock/us-gov-west-1/anthropic.claude-fable-5-1": {
"cache_creation_input_token_cost": 1.5e-05,
"cache_creation_input_token_cost_above_1hr": 2.4e-05,
@ -61013,6 +61430,41 @@
"supports_vision": true,
"supports_xhigh_reasoning_effort": true
},
"bedrock/us-gov-east-1/anthropic.claude-opus-5-5": {
"bedrock_converse_supports_strict_tools": false,
"cache_creation_input_token_cost": 6e-06,
"cache_creation_input_token_cost_above_1hr": 9.6e-06,
"cache_read_input_token_cost": 2.4e-07,
"input_cost_per_token": 4.8e-06,
"litellm_provider": "bedrock",
"supports_tool_search": true,
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.4e-05,
"prompt_cache_min_tokens": 512,
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_mid_conversation_system": true,
"supports_native_structured_output": false,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"thinking_always_on": true,
"supports_forced_tool_use": false,
"source": "https://aws.amazon.com/bedrock/pricing/"
},
"bedrock/us-gov-east-1/anthropic.claude-fable-5-1": {
"cache_creation_input_token_cost": 1.5e-05,
"cache_creation_input_token_cost_above_1hr": 2.4e-05,
@ -71883,5 +72335,68 @@
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"xai/grok-code-fast": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_200k_tokens": 2e-06,
"litellm_provider": "xai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"output_cost_per_token_above_200k_tokens": 4e-06,
"source": "https://api.x.ai/v1/language-models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"xai/grok-code-fast-1": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_200k_tokens": 2e-06,
"litellm_provider": "xai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"output_cost_per_token_above_200k_tokens": 4e-06,
"source": "https://api.x.ai/v1/language-models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"xai/grok-code-fast-1-0825": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_image_token": 1e-06,
"input_cost_per_token": 1e-06,
"input_cost_per_token_above_200k_tokens": 2e-06,
"litellm_provider": "xai",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"output_cost_per_token_above_200k_tokens": 4e-06,
"source": "https://api.x.ai/v1/language-models",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
}
}

57
tests/_process_helpers.py Normal file
View file

@ -0,0 +1,57 @@
"""Whether a killed process is really gone, for tests that kill whole process trees.
A SIGKILLed grandchild whose parent died in the same ``killpg`` reparents to the
nearest subreaper or PID 1, and until that ancestor reaps it the pid is a zombie
that ``os.kill(pid, 0)`` still accepts. Reading its ``/proc`` state, and reaping
it when it landed on this process, keeps a runner that is slow to reap, or never
does, from turning a dead process into a failed assertion. The reap comes after
the liveness read so a child seen dying between the two is still collected on
the next poll instead of staying this process's own zombie.
"""
import os
import time
from pathlib import Path
from typing import Final
POLL_INTERVAL_S: Final = 0.05
def _exists(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
return True
def _is_zombie(pid: int) -> bool:
try:
stat: Final = Path(f"/proc/{pid}/stat").read_text()
except OSError:
return False
return stat.rpartition(")")[2].split()[0] == "Z"
def _reap_if_ours(pid: int) -> None:
if os.name == "nt":
return
try:
os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
pass
def _gone_now(pid: int) -> bool:
dead: Final = not _exists(pid) or _is_zombie(pid)
_reap_if_ours(pid)
return dead
def process_is_gone(pid: int, within_seconds: float) -> bool:
deadline: Final = time.monotonic() + within_seconds
while time.monotonic() < deadline:
if _gone_now(pid):
return True
time.sleep(POLL_INTERVAL_S)
return False

View file

@ -43,7 +43,7 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family
- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server (see "MCP suite: real Datadog only" below); plus the gateway-managed OAuth (authorization_code) path exercised through `/chat/completions` in `test_mcp_chat_completion_oauth_e2e.py` and direct MCP protocol operations in `test_mcp_oauth_happy_path_e2e.py`, the one behavior Datadog's static-header auth cannot reach, seeding the per-user upstream token via the interactive authorize dance driven with the mcp SDK's own OAuth client (headless-browser consent from a saved session) and asserting the completion or protocol call lists and executes the server's tools with the stored per-user token
- `logging/` - logging-integration delivery (datadog and friends)
- `security/` - secret handling and log-leak protection
- `router/` - routing and reliability behavior (fallbacks, cooldowns) plus the memory regression test (`test_reliability_memory_e2e.py`: a few hundred failing requests with retries and fallbacks must not grow proxy RSS past a fixed budget nor store a request snapshot past a fixed size, the release-gate check for the v1.100.0 retry-breadcrumb leak)
- `router/` - routing and reliability behavior (fallbacks, cooldowns) plus the memory tests (`test_reliability_memory_e2e.py`: every worker's RSS as read at collection time, before any test traffic, must sit under a fixed idle budget, the release-gate check for a DB-backed boot that idles near the pod limit the way v1.100.x did; and a few hundred failing requests with retries and fallbacks must not grow proxy RSS past a fixed budget nor store a request snapshot past a fixed size, the release-gate check for the v1.100.0 retry-breadcrumb leak)
- `load/` - performance-category tests, kept OUT of the main suite: throughput/load SLO tests are a different testing category from functional e2e (variance-driven, historically flaky) and live outside this suite until re-implemented as their own pipeline (LIT-5163); do not add a live load test that runs in the default collection. What lives here: the weekly session-anomaly test (`test_weekly_session_anomaly_e2e.py`, Claude Code-shaped multi-turn sessions against real providers with ceilings on error rate, cache read/write, turn time, and spend; marked `weekly` and deselected unless `E2E_WEEKLY_ANOMALY` is set, driven by `.github/workflows/weekly_load_anomaly.yml`), the Redis chaos test (`test_redis_chaos_e2e.py`, locust load against mock deployments split round robin over `/chat/completions` and `/v1/messages`, one endpoint per simulated user, with `CLIENT PAUSE ALL` on the proxy's Redis mid-run to simulate it being down outright, asserting zero failed requests on every endpoint, budgeting RSS and CPU-per-request as ratios against the same run's healthy phase, and holding p50/p90/p99 latency and log-bytes-per-request to flat ceilings (a ratio cannot bound those two: an open breaker skips Redis instead of waiting on it, so the chaos phase can measure cheaper than baseline while still being far slower than a user should see); needs a proxy booted from `gateway/redis_chaos_ci_config.yml` on the same host with `E2E_PROXY_PID` and `E2E_PROXY_LOG` set, marked `redis_chaos`, deselected unless `E2E_REDIS_CHAOS` is set and excluded from the per-PR selector like the rest of `load/`, driven by `.github/workflows/test-e2e-redis-chaos.yml` and by the Buildkite `e2e-redis-chaos` step in project-releaser, which runs the proxy, Postgres and Valkey co-located with pytest in one pod and sets the opt-in), and markerless harness unit tests for the locust, process-usage, and session-anomaly aggregation logic
- `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate, JWT auth (access tokens issued by a real Keycloak realm, `idp.py` plus `idp_realm.json`, whose JWKS the proxy's `JWT_PUBLIC_KEY_URL` points at; see CONTRIBUTING.md for the start command and config block), and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite
- `gateway/` - proxy configuration only (`litellm-config.yml`); no tests
@ -97,7 +97,7 @@ Each suite provides its own `client` fixture (see `llm_translation/passthrough_c
Request and response bodies are typed pydantic models in `models.py`; only the fields a test reads are modelled, and nothing passes raw dicts. Outcomes come back as a `Result[R]` tagged union (`Success`, `NetworkError`, `UnauthorizedError`, `RateLimitedError`, `ValidationError`, `UnknownApiError`). Handle them with `match`, or call `unwrap(...)` when a non-success should fail the test. The harness hard-fails and never skips: a test marked `e2e` fails when no proxy answers its liveness probe, and once a request reaches the proxy any wrong behavior is likewise a hard failure, so a missing proxy turns the run red instead of being mistaken for a pass
Mark live tests with `@pytest.mark.e2e` (on the class or the module). Use `scoped_key` for a fresh all-models key that auto-deletes, `resources` when you need to create and tear down more than a key, and `unique_marker()` from `e2e_config` to keep prompts, tags, and customer ids from colliding across concurrent runs and the shared response cache
Mark live tests with `@pytest.mark.e2e` (on the class or the module). Add `@pytest.mark.quiet_stack` to a test that measures the proxy itself (RSS, latency): the shared stack lock in `stack_lock.py` then runs it while no other test on the host is hitting the stack, marked or not, so the reading depends only on the test's own traffic. Use `scoped_key` for a fresh all-models key that auto-deletes, `resources` when you need to create and tear down more than a key, and `unique_marker()` from `e2e_config` to keep prompts, tags, and customer ids from colliding across concurrent runs and the shared response cache
## Record and replay fixtures
@ -191,7 +191,7 @@ reliability.<behavior>.<variant>.<assertion>
behavior : fallback | retry | cooldown | timeout | routing | cache | circuit_breaker | perf
variant : <trigger> 5xx | context_window | content_policy | 429 | timeout
<strategy> simple_shuffle | usage_based | latency_based | cost_based | least_busy
<dimension> latency | throughput | session_anomaly | memory (perf only; SLO/threshold assertion, not binary)
<dimension> latency | throughput | session_anomaly | memory | idle_memory (perf only; SLO/threshold assertion, not binary)
assertion : routes_to_fallback | succeeds_within_retries | picks_under_tpm | returns_cached
| trips_then_recovers | under_slo
e.g. reliability.fallback.context_window.routes_to_fallback exercised_on=[chat_completions]

View file

@ -46,13 +46,18 @@ from fixture_mode import pytest_fixture_setup as pytest_fixture_setup
from idp import Identity, Keycloak, keycloak_from_env
from junit_properties import attach_result_properties
from lifecycle import ProxyClientProvider, ResourceManager
from memory_readings import RssCapture, read_rss_everywhere
from models import TeamNewBody, UserNewBody, UserNewResponse
from provider_cache_routing import LIVE_PROVIDER_REQUIRED
from provider_edge import replay_leftover_error
from proxy_client import ProxyClient, build_proxy_client
from stack_lock import stack_lock
_E2E_TEST_RAN = pytest.StashKey[bool]()
_CALL_PASSED = pytest.StashKey[bool]()
_IDLE_RSS = pytest.StashKey[RssCapture]()
IDLE_RSS_READ_TIMEOUT_SECONDS: Final = 10.0
OPT_IN_MARKERS: Final = MappingProxyType(
{
@ -144,6 +149,11 @@ def pytest_configure(config: pytest.Config) -> None:
"redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from "
"gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_REDIS_CHAOS is set",
)
config.addinivalue_line(
"markers",
"quiet_stack: measures the proxy itself, so it runs while no other test on this host is hitting the stack; "
"every other test waits for it to finish",
)
config.addinivalue_line(
"markers",
"mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless "
@ -168,9 +178,7 @@ def pytest_sessionstart(session: pytest.Session) -> None:
"""Abort before collection when E2E_FIXTURE_MODE can never work: an unknown
mode value, or replay against a missing, unreadable, or stale bundle (the
stale message names the bundle's age). Live and record modes pass through."""
reason = fixture_mode_collection_error(
FIXTURE_MODE_RAW, FIXTURE_DIR, now=datetime.now(timezone.utc)
)
reason = fixture_mode_collection_error(FIXTURE_MODE_RAW, FIXTURE_DIR, now=datetime.now(timezone.utc))
if reason is not None:
raise pytest.UsageError(reason)
@ -186,6 +194,16 @@ def _needs_unset_opt_in(item: pytest.Item) -> bool:
)
def _reaches_proxy(item: pytest.Item) -> bool:
"""True for a live test that talks to the shared proxy: `e2e`-marked and not a
`migration_startup` test, which boots its own container instead."""
return item.get_closest_marker("e2e") is not None and item.get_closest_marker("migration_startup") is None
def _uses_idle_rss(item: pytest.Item) -> bool:
return isinstance(item, pytest.Function) and "idle_rss" in item.fixturenames
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Deselect every test behind an opt-in marker whose env var is unset (see
OPT_IN_MARKERS): those tests need a proxy configured differently from the
@ -215,6 +233,20 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item
items.sort(key=lambda item: item.get_closest_marker("load") is not None)
@pytest.hookimpl(tryfirst=True)
def pytest_collection_finish(session: pytest.Session) -> None:
"""When a selected test asks for the `idle_rss` fixture and this is not a
`--collect-only` run, read every replica's RSS once, right here at the end of
collection and before this process sends any traffic. tryfirst keeps the read
ahead of xdist's own collection-finish report, and the controller schedules no
test until every worker has reported, so this is the idle footprint of a stack
that just passed its readiness gate. The fixture hands the capture to the
idle-budget test in router/test_reliability_memory_e2e.py."""
if session.config.getoption("collectonly") or not any(_uses_idle_rss(item) for item in session.items):
return
session.config.stash[_IDLE_RSS] = read_rss_everywhere(build_proxy_client(), timeout=IDLE_RSS_READ_TIMEOUT_SECONDS)
def _liveness_reason(label: str, base_url: str) -> str | None:
"""None if `base_url` answers its liveness probe, else a failure reason."""
try:
@ -239,6 +271,12 @@ def _proxy_fail_reason() -> str | None:
return None
@pytest.hookimpl(wrapper=True)
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None) -> Generator[None, object, object]:
with stack_lock(exclusive=item.get_closest_marker("quiet_stack") is not None):
return (yield)
@pytest.hookimpl(tryfirst=True)
def pytest_runtest_setup(item: pytest.Item) -> None:
"""Hard-fail `e2e`-marked tests unless a proxy answers its liveness probe.
@ -246,7 +284,9 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
run even when none is up. Never skip for a missing proxy. Replay mode needs
the proxy too: only provider-bound traffic replays from the bundle."""
LIVE_PROVIDER_REQUIRED.set(item.get_closest_marker("provider_live") is not None)
if item.get_closest_marker("e2e") is None or item.get_closest_marker("migration_startup") is not None:
if _uses_idle_rss(item):
item.user_properties.extend(item.config.stash[_IDLE_RSS].junit_properties)
if not _reaches_proxy(item):
return
if isinstance(item, pytest.Function) and "oauth_gateway" in item.fixturenames:
return
@ -261,7 +301,7 @@ def pytest_runtest_call(item: pytest.Item) -> None:
guard before truncating the spend-log DB. Tests under `tests/e2e/` without the
`e2e` marker (pure unit coverage for the harness itself) never hit the proxy,
so they must not arm the destructive DB truncate."""
if item.get_closest_marker("e2e") is None or item.get_closest_marker("migration_startup") is not None:
if not _reaches_proxy(item):
return
item.session.stash[_E2E_TEST_RAN] = True
@ -296,9 +336,7 @@ def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]:
LIVE_PROVIDER_REQUIRED.set(False)
if not item.stash.get(_CALL_PASSED, False):
return result
reason = replay_leftover_error(
mode_raw=FIXTURE_MODE_RAW, bundle_dir=FIXTURE_DIR, test_key=item.nodeid
)
reason = replay_leftover_error(mode_raw=FIXTURE_MODE_RAW, bundle_dir=FIXTURE_DIR, test_key=item.nodeid)
if reason is not None:
pytest.fail(reason)
return result
@ -325,6 +363,13 @@ def proxy() -> ProxyClient:
return build_proxy_client()
@pytest.fixture(scope="session")
def idle_rss(request: pytest.FixtureRequest) -> RssCapture:
"""Every replica's RSS as read once at the end of collection, before this process
sent any traffic (see pytest_collection_finish)."""
return request.config.stash[_IDLE_RSS]
@pytest.fixture
def resources(client: ProxyClientProvider) -> Iterator[ResourceManager]:
"""init -> run -> teardown: create a manager, run the test, release resources.

View file

@ -51,6 +51,7 @@
- {id: quota_management.spend_tracking.end_user.attributes_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: end_user, assertions: [attributes_spend], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "user= attribution lands the end-user id on the spend row"}
- {id: quota_management.spend_tracking.per_model.writes_own_rows, module: quota_management, tier: P2, behavior: spend_tracking, variant: per_model, assertions: [writes_own_rows], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Each model on a shared key gets its own spend row"}
- {id: quota_management.spend_tracking.failure.writes_failure_row, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [writes_failure_row], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_log_error_logger.py", rationale: "A failed call writes a failure-status spend row"}
- {id: quota_management.spend_tracking.failure.writes_normalized_error, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [writes_normalized_error], exercised_on: [chat_completions], source: "litellm_core_utils/error_normalization.py", rationale: "Failure rows carry a stable metadata.error_information.normalized_error key next to the unchanged error_message, so two upstream auth failures with different provider wording share one cluster key a dashboard can group by"}
- {id: quota_management.spend_tracking.failure.attributes_provider, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [attributes_provider], exercised_on: [chat_completions], source: "proxy/utils.py", rationale: "A request rejected in pre_call_hook (rate limit, guardrail) still lands its single deployment's provider and model_id on the failure spend row"}
- {id: quota_management.spend_tracking.spend_calculate.returns_cost, module: quota_management, tier: P2, behavior: spend_tracking, variant: spend_calculate, assertions: [returns_cost], exercised_on: [spend_calculate], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "/spend/calculate prices a hypothetical request at nonzero cost"}
- {id: quota_management.spend_tracking.pagination.keeps_total, module: quota_management, tier: P2, behavior: spend_tracking, variant: pagination, assertions: [keeps_total], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "Spend-logs v2 pagination caps page size without losing the total"}

View file

@ -38,4 +38,5 @@
- {id: reliability.timeout.stream_timeout.exceeds_deadline, module: reliability, tier: P1, behavior: timeout, variant: stream_timeout, assertions: [exceeds_deadline], exercised_on: [chat_completions], source: "litellm/router.py:551", rationale: "Streaming chunk-delivery timeout"}
- {id: reliability.perf.throughput.under_slo, module: reliability, tier: P1, behavior: perf, variant: throughput, assertions: [under_slo], exercised_on: [chat_completions, messages], source: grammar, rationale: "Throughput SLO under load"}
- {id: reliability.perf.memory.under_slo, module: reliability, tier: P1, behavior: perf, variant: memory, assertions: [under_slo], exercised_on: [chat_completions], source: grammar, rationale: "Proxy RSS and the stored request snapshot stay within fixed budgets across a few hundred failing requests with retries and fallbacks, the v1.100.0 retry-breadcrumb leak shape (MAT-335)"}
- {id: reliability.perf.idle_memory.under_slo, module: reliability, tier: P1, behavior: perf, variant: idle_memory, assertions: [under_slo], exercised_on: [], source: grammar, rationale: "Every worker's RSS as read right after the readiness gate and before any test traffic stays under a fixed idle budget; a DB-backed v1.100.x worker idled at 886 MB against a 2 GiB pod limit where v1.101.0rc1 idled at 544 MB"}
- {id: reliability.perf.session_anomaly.under_slo, module: reliability, tier: P1, behavior: perf, variant: session_anomaly, assertions: [under_slo], exercised_on: [messages], source: grammar, rationale: "Weekly Claude Code-shaped multi-turn session load against real providers; ceilings on error rate, warm-turn cache read/write, p95 turn time, and gateway-recorded spend (LIT-4562)"}

View file

@ -173,6 +173,7 @@ MEMORY_CONCURRENCY = int(os.environ.get("E2E_MEMORY_CONCURRENCY", "4"))
MEMORY_RSS_SETTLE_SAMPLES = int(os.environ.get("E2E_MEMORY_RSS_SETTLE_SAMPLES", "15"))
MEMORY_RSS_SAMPLE_INTERVAL_SECONDS = float(os.environ.get("E2E_MEMORY_RSS_SAMPLE_INTERVAL_SECONDS", "1"))
MEMORY_RSS_BUDGET_MB = float(os.environ.get("E2E_MEMORY_RSS_BUDGET_MB", "48"))
MEMORY_IDLE_RSS_BUDGET_MB = float(os.environ.get("E2E_MEMORY_IDLE_RSS_BUDGET_MB", "768"))
MEMORY_STORED_REQUEST_BUDGET_KB = float(os.environ.get("E2E_MEMORY_STORED_REQUEST_BUDGET_KB", "64"))

View file

@ -0,0 +1,78 @@
"""Per-worker RSS readings of the proxy through /debug/memory/summary.
One read goes to every configured replica (PROXY_REPLICA_URLS) under the master
key and answers from whichever worker behind that address took the connection;
the release stack runs one worker per gateway replica, so a read per replica is
a read per worker. A reading keys its worker by replica address, hostname, and
pid, since pods in their own pid namespaces report the same pids. A replica that
gives no reading (unreachable, a non-2xx, or a summary without ram_usage_mb) is
kept as a failure reason rather than dropped, so a test can fail on it by name
instead of passing on the replicas that did answer.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final
from e2e_http import Result, Success
from models import MemorySummaryResponse
from proxy_client import ProxyClient
WorkerKey = tuple[str, str | None, int]
@dataclass(frozen=True, slots=True)
class RssReading:
replica: str
hostname: str | None
worker_pid: int
ram_usage_mb: float
@property
def worker(self) -> WorkerKey:
return (self.replica, self.hostname, self.worker_pid)
@property
def where(self) -> str:
return f"worker pid {self.worker_pid} on {self.hostname or 'an unnamed host'} behind {self.replica}"
@dataclass(frozen=True, slots=True)
class RssCapture:
readings: tuple[RssReading, ...]
failures: tuple[str, ...]
@property
def heaviest(self) -> RssReading | None:
return max(self.readings, key=lambda reading: reading.ram_usage_mb, default=None)
@property
def junit_properties(self) -> tuple[tuple[str, object], ...]:
heaviest: Final = self.heaviest
if heaviest is None:
return ()
return (("idle_rss_heaviest_mb", heaviest.ram_usage_mb), ("idle_rss_heaviest_worker", heaviest.where))
def _outcome(replica: str, result: Result[MemorySummaryResponse]) -> RssReading | str:
match result:
case Success(data=body) if body.memory.ram_usage_mb is not None:
return RssReading(replica, body.hostname, body.worker_pid, body.memory.ram_usage_mb)
case Success(data=body):
return f"{replica} answered /debug/memory/summary without ram_usage_mb: {body.memory.error}"
case _:
return f"{replica} gave no /debug/memory/summary reading: {result}"
def rss_capture(summaries: Mapping[str, Result[MemorySummaryResponse]]) -> RssCapture:
outcomes: Final = tuple(_outcome(replica, result) for replica, result in summaries.items())
return RssCapture(
readings=tuple(outcome for outcome in outcomes if isinstance(outcome, RssReading)),
failures=tuple(outcome for outcome in outcomes if isinstance(outcome, str)),
)
def read_rss_everywhere(proxy: ProxyClient, *, timeout: float | None = None) -> RssCapture:
return rss_capture(proxy.memory_summary_everywhere(timeout=timeout))

View file

@ -937,10 +937,18 @@ class GuardrailRunRecord(BaseModel):
guardrail_response: object | None = None
class SpendLogErrorInformation(BaseModel):
error_code: str | None = None
error_class: str | None = None
error_message: str | None = None
normalized_error: str | None = None
class SpendLogMetadata(BaseModel):
user_api_key_alias: str | None = None
applied_guardrails: list[str] | None = None
guardrail_information: list[GuardrailRunRecord] | None = None
error_information: SpendLogErrorInformation | None = None
class SpendLogRow(BaseModel):

View file

@ -508,13 +508,16 @@ class ProxyClient:
)
).info
def memory_summary_everywhere(self) -> Mapping[str, Result[MemorySummaryResponse]]:
def memory_summary_everywhere(
self, *, timeout: float | None = None
) -> Mapping[str, Result[MemorySummaryResponse]]:
return {
url: transport.get(
"/debug/memory/summary",
headers=self.management_headers(transport=transport),
params=NoBody(),
response_type=MemorySummaryResponse,
timeout=timeout,
)
for url, transport in self.replicas.items()
}

View file

@ -12,6 +12,7 @@ markers =
prompt_caching_stack: needs a proxy running with router_settings.optional_pre_call_checks including prompt_caching; deselected unless E2E_PROMPT_CACHING_STACK is set
cli_determinism: drives the real claude CLI for several seconds; deselected unless E2E_CLI_DETERMINISM is set
redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_REDIS_CHAOS is set
quiet_stack: measures the proxy itself, so it runs while no other test on this host is hitting the stack; every other test waits for it to finish
mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless E2E_MCP_OAUTH_LIVE is set
provider_edge_host: routes provider traffic through the pytest host's edge in every fixture mode, so the gateway must reach the pytest host; deselected unless E2E_PROVIDER_EDGE_HOST_REACHABLE is set
otel_v2: needs a proxy running with LITELLM_OTEL_V2=true; deselected unless E2E_OTEL_V2 is set

View file

@ -499,6 +499,47 @@ def test_failure_call_writes_failure_status_row(
assert (failure_row.spend or 0) == 0.0, "failed call must not be charged"
@pytest.mark.covers("quota_management.spend_tracking.failure.writes_normalized_error")
def test_failure_rows_share_normalized_error_across_provider_wording(
client: SpendClient, resources: ResourceManager, scoped_key: str
) -> None:
"""Two upstream auth failures with different provider wording land as failure rows
whose metadata.error_information keeps each provider's own error_message and
carries the same stable normalized_error cluster key."""
marker = unique_marker()
deployments: Final = (
(f"e2e-norm-openai-{marker}", "openai/gpt-5.5"),
(f"e2e-norm-anthropic-{marker}", "anthropic/claude-haiku-4-5"),
)
for name, provider_model in deployments:
model_id = client.proxy.create_model(
name, LiteLLMParamsBody(model=provider_model, api_key=f"sk-invalid-{marker}")
)
resources.defer(lambda model_id=model_id: client.proxy.delete_model(model_id))
result = client.chat(scoped_key, name, f"normalize failure {marker}", max_tokens=1)
assert not is_ok(result), f"{name}: invalid upstream key must fail the call, got {result}"
rows = client.poll_logs_for_key(
scoped_key,
min_rows=2,
predicate=lambda rs: sum(1 for r in rs if r.status == "failure") >= 2,
)
failure_rows = [r for r in rows if r.status == "failure"]
assert len(failure_rows) == 2, f"expected one failure row per deployment: {_summarize(rows)}"
infos = [r.metadata.error_information if r.metadata else None for r in failure_rows]
assert all(info is not None for info in infos), (
f"failure rows must carry metadata.error_information: {[r.model_dump() for r in failure_rows]}"
)
messages = {info.error_message for info in infos if info is not None}
assert len(messages) == 2, f"provider wording must stay distinct in error_message: {messages}"
normalized = {info.normalized_error for info in infos if info is not None}
assert normalized == {"401_AUTHENTICATION_FAILED"}, (
f"both auth failures must share one normalized_error cluster key; saw {normalized} "
f"for messages {messages}"
)
@pytest.mark.covers("quota_management.spend_tracking.failure.attributes_provider")
def test_pre_call_rejection_row_attributes_provider_and_model_id(
client: SpendClient, resources: ResourceManager

View file

@ -38,6 +38,20 @@ size budget: the deterministic catch for a breadcrumb that copies the whole
request. It runs before the phases because the leaking writer drops its own rows
under the phases' traffic (a queue budget hit, a recursion limit on the nested
copies), which would turn the size check into a missing-row check.
A second, cheaper check holds the idle footprint: every worker's RSS as the harness
read it at collection time, before this pytest process sent any traffic (see
conftest.pytest_collection_finish), must sit under a fixed budget. On the
release gate that is a fresh stack right after its readiness gate, one worker per
gateway replica, so the reading is what a DB-backed boot costs on its own. A
v1.100.x worker with a database idled at 886 MB RSS where v1.101.0rc1 idled at
544 MB on the same database (1.3 GB against 560 MB at the pod level, under a
2 GiB limit): the generated Prisma client at prisma-client-py's default recursive
type depth, 91k TypedDict classes that v1.101.0 cut to 19k with
recursive_type_depth = -1. The budget starts at the rc1 reading plus headroom and
E2E_MEMORY_IDLE_RSS_BUDGET_MB overrides it; a later session on the same stack (the
changed-files workflow's repeat passes, a developer's local loop) measures a proxy
already warmed by traffic, which that headroom also has to cover.
"""
from __future__ import annotations
@ -55,6 +69,7 @@ import pytest
from complexity_router_client import ComplexityRouterClient
from e2e_config import (
MEMORY_CONCURRENCY,
MEMORY_IDLE_RSS_BUDGET_MB,
MEMORY_REQUESTS_PER_PHASE,
MEMORY_RETRIES_PER_REQUEST,
MEMORY_RSS_BUDGET_MB,
@ -62,15 +77,16 @@ from e2e_config import (
MEMORY_RSS_SETTLE_SAMPLES,
MEMORY_STORED_REQUEST_BUDGET_KB,
MEMORY_TRANSCRIPT_TURNS,
PROXY_REPLICA_URLS,
unique_marker,
)
from e2e_http import unwrap
from lifecycle import ResourceManager
from memory_readings import RssCapture, RssReading, WorkerKey, read_rss_everywhere
from models import ChatMessage, RouterSettingsOverride, SpendLogRow
from proxy_client import ProxyClient
from reliability_support import chat_override, create_never_benched_refusing_deployment
pytestmark = pytest.mark.e2e
pytestmark = [pytest.mark.e2e, pytest.mark.quiet_stack]
DEPLOYMENTS_PER_GROUP: Final = 2
RSS_SAMPLE_CAP: Final = 4 * MEMORY_RSS_SETTLE_SAMPLES
@ -84,21 +100,6 @@ class FailedCall:
call_id: str | None
WorkerKey = tuple[str, str | None, int]
@dataclass(frozen=True, slots=True)
class RssReading:
replica: str
hostname: str | None
worker_pid: int
ram_usage_mb: float
@property
def worker(self) -> WorkerKey:
return (self.replica, self.hostname, self.worker_pid)
@dataclass(frozen=True, slots=True)
class WorkerGrowth:
warm: RssReading
@ -143,12 +144,12 @@ def _fail_many(proxy: ProxyClient, key: str, model: str, override: RouterSetting
def _read_rss_everywhere_after_pause(proxy: ProxyClient) -> tuple[RssReading, ...]:
time.sleep(MEMORY_RSS_SAMPLE_INTERVAL_SECONDS)
return tuple(
RssReading(replica, body.hostname, body.worker_pid, body.memory.ram_usage_mb)
for replica, result in proxy.memory_summary_everywhere().items()
for body in (unwrap(result),)
if body.memory.ram_usage_mb is not None
capture: Final = read_rss_everywhere(proxy)
assert not capture.failures, (
f"{len(capture.failures)} replica(s) gave no RSS reading mid-checkpoint, so their workers cannot be "
f"compared with themselves: {'; '.join(capture.failures)}"
)
return capture.readings
def _readings_until_no_new_worker(
@ -220,6 +221,26 @@ def _stored_request_kb(proxy: ProxyClient, call: FailedCall) -> float:
class TestReliabilityMemory:
@pytest.mark.covers("reliability.perf.idle_memory.under_slo")
def test_workers_idle_under_rss_budget_before_traffic(self, idle_rss: RssCapture) -> None:
assert not idle_rss.failures, (
f"{len(idle_rss.failures)} replica(s) gave no RSS reading when the session started, so their idle "
f"footprint went unmeasured: {'; '.join(idle_rss.failures)}"
)
unmeasured: Final = frozenset(PROXY_REPLICA_URLS) - frozenset(reading.replica for reading in idle_rss.readings)
assert not unmeasured, (
f"{len(unmeasured)} of {len(PROXY_REPLICA_URLS)} replica(s) gave neither an RSS reading nor a failure "
f"reason when the session started, so their idle footprint went unmeasured: {', '.join(sorted(unmeasured))}"
)
heaviest: Final = idle_rss.heaviest
assert heaviest is not None, "no replica was configured to read, so nothing was measured"
assert heaviest.ram_usage_mb <= MEMORY_IDLE_RSS_BUDGET_MB, (
f"{heaviest.where} sat at {heaviest.ram_usage_mb:.0f} MB RSS when the session started, before it sent "
f"any traffic, past the {MEMORY_IDLE_RSS_BUDGET_MB:.0f} MB idle budget; a DB-backed v1.100.x worker idled "
f"at 886 MB where v1.101.0rc1 idled at 544 MB, and at that size the release stack's 2 GiB pod limit "
f"leaves the worker little room for traffic"
)
@pytest.mark.covers("reliability.perf.memory.under_slo")
def test_failing_requests_do_not_grow_rss_or_stored_request(
self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str

45
tests/e2e/stack_lock.py Normal file
View file

@ -0,0 +1,45 @@
"""Cross-process reader/writer lock over the proxy stack every xdist worker shares.
Every collected test holds it shared, marker or not, since the Claude Code cells and
other unmarked suites drive the same stack; a `quiet_stack` test holds it exclusive,
and the `gate` file makes a waiting exclusive holder win over readers that arrive
after it."""
from __future__ import annotations
import fcntl
import hashlib
import tempfile
from collections.abc import Generator
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Final
from e2e_config import PROXY_BASE_URL
STACK_DIGEST: Final = hashlib.sha256(PROXY_BASE_URL.encode()).hexdigest()[:12]
LOCK_DIR: Final = Path(tempfile.gettempdir()) / f"litellm-e2e-stack-{STACK_DIGEST}"
GATE_FILE: Final = LOCK_DIR / "gate"
STACK_FILE: Final = LOCK_DIR / "stack"
@contextmanager
def _flock(path: Path, operation: int) -> Generator[None]:
with path.open("a") as handle:
fcntl.flock(handle, operation)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
@contextmanager
def stack_lock(exclusive: bool) -> Generator[None]:
LOCK_DIR.mkdir(parents=True, exist_ok=True)
if exclusive:
with _flock(GATE_FILE, fcntl.LOCK_EX), _flock(STACK_FILE, fcntl.LOCK_EX):
yield
return
with ExitStack() as held:
with _flock(GATE_FILE, fcntl.LOCK_SH):
held.enter_context(_flock(STACK_FILE, fcntl.LOCK_SH))
yield

View file

@ -0,0 +1,117 @@
"""Cross-process behavior of the stack lock: readers share it, an exclusive holder waits for
every reader and keeps them out, and a reader arriving behind a waiting exclusive holder
queues behind it instead of starving it."""
from __future__ import annotations
import fcntl
import os
import subprocess
import sys
import time
from contextlib import ExitStack
from pathlib import Path
from typing import Final
import pytest
from stack_lock import STACK_DIGEST
HARNESS_DIR: Final = Path(__file__).resolve().parent
DEADLINE_SECONDS: Final = 30.0
SETTLE_SECONDS: Final = 0.5
HOLDER_SCRIPT: Final = """
import sys, time
from pathlib import Path
from stack_lock import stack_lock
name, mode, release_path, log_path = sys.argv[1:]
def record(event):
with Path(log_path).open("a") as log:
log.write(f"{name} {event}\\n")
record("waiting")
with stack_lock(exclusive=mode == "exclusive"):
record("enter")
while not Path(release_path).exists():
time.sleep(0.02)
record("exit")
"""
def _events(log_path: Path) -> tuple[str, ...]:
return tuple(log_path.read_text().splitlines()) if log_path.exists() else ()
def _wait_for_event(log_path: Path, event: str) -> None:
deadline: Final = time.monotonic() + DEADLINE_SECONDS
while event not in _events(log_path):
if time.monotonic() > deadline:
pytest.fail(f"{event!r} never appeared; events so far: {_events(log_path)}")
time.sleep(0.02)
def _wait_until_gate_is_held_exclusively(gate_path: Path) -> None:
deadline: Final = time.monotonic() + DEADLINE_SECONDS
with gate_path.open("a") as handle:
while True:
try:
fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError:
return
fcntl.flock(handle, fcntl.LOCK_UN)
if time.monotonic() > deadline:
pytest.fail("no exclusive holder ever took the gate")
time.sleep(0.02)
def _start_holder(held: ExitStack, tmp_path: Path, name: str, mode: str) -> subprocess.Popen[bytes]:
holder: Final = held.enter_context(
subprocess.Popen(
(
sys.executable,
"-P",
"-c",
HOLDER_SCRIPT,
name,
mode,
str(tmp_path / f"release-{name}"),
str(tmp_path / "events"),
),
cwd=HARNESS_DIR,
env={**os.environ, "TMPDIR": str(tmp_path), "PYTHONPATH": str(HARNESS_DIR)},
)
)
held.callback(holder.kill)
return holder
def test_readers_share_exclusive_waits_and_a_waiting_exclusive_beats_later_readers(tmp_path: Path) -> None:
lock_dir: Final = tmp_path / f"litellm-e2e-stack-{STACK_DIGEST}"
lock_dir.mkdir()
log_path: Final = tmp_path / "events"
with ExitStack() as held:
first_reader: Final = _start_holder(held, tmp_path, "A", "shared")
_wait_for_event(log_path, "A enter")
second_reader: Final = _start_holder(held, tmp_path, "R", "shared")
_wait_for_event(log_path, "R enter")
(tmp_path / "release-R").touch()
_wait_for_event(log_path, "R exit")
writer: Final = _start_holder(held, tmp_path, "W", "exclusive")
_wait_until_gate_is_held_exclusively(lock_dir / "gate")
late_reader: Final = _start_holder(held, tmp_path, "B", "shared")
_wait_for_event(log_path, "B waiting")
time.sleep(SETTLE_SECONDS)
(tmp_path / "release-A").touch()
_wait_for_event(log_path, "W enter")
(tmp_path / "release-W").touch()
_wait_for_event(log_path, "B enter")
(tmp_path / "release-B").touch()
for holder in (first_reader, second_reader, writer, late_reader):
assert holder.wait(timeout=DEADLINE_SECONDS) == 0
events: Final = _events(log_path)
assert events.index("R enter") < events.index("A exit")
assert events.index("W enter") > events.index("A exit")
assert events.index("B enter") > events.index("W exit")

View file

@ -159,6 +159,12 @@
"tests/integration/routing/test_redis_recovery.py::test_owned_redis_outage_recovers_requests_and_real_response_cache": [
"other.routing.redis.owned_outage_recovers_serving_and_response_cache"
],
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_bare_string_content_item_is_rejected_as_client_error_before_the_wire[type_word]": [
"other.provider_wire.anthropic.bare_string_content_item_is_client_error"
],
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_bare_string_content_item_is_rejected_as_client_error_before_the_wire[plain]": [
"other.provider_wire.anthropic.bare_string_content_item_is_client_error"
],
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_tool_history_and_cache_tokens_keep_wire_and_accounting_contracts": [
"other.provider_wire.anthropic.tool_history_system_cache_and_internal_fields",
"quota_management.spend_tracking.cache_tokens.disjoint_classes_use_explicit_rates"
@ -275,6 +281,9 @@
"tests/integration/spend/test_filtered_ledger.py::test_rotated_keys_users_and_model_groups_preserve_success_failure_cache_ledger": [
"quota_management.spend_tracking.filtered_ledger_preserves_owner_identity_and_totals"
],
"tests/integration/spend/test_spend_calculate.py::test_spend_calculate_rejects_unpriced_model_with_400": [
"quota_management.spend_tracking.spend_calculate.rejects_unpriced_model"
],
"tests/integration/management/test_partial_update_sequences.py::test_restricted_actor_cannot_detach_key_from_project": [
"mgmt.key.update.project_detach_denied_to_restricted_actor"
],

View file

@ -3,7 +3,6 @@ import uuid
from typing import Final
import pytest
from integration._support.client import Gateway, eventually, object_value
from integration._support.database import read_rows
from integration._support.wire import Reply, Request, wire_server
@ -59,3 +58,26 @@ def test_anthropic_tool_history_and_cache_tokens_keep_wire_and_accounting_contra
parsed: Final = json.loads(metadata) if isinstance(metadata, str) else object_value(metadata)
assert parsed["cost_breakdown"]["input_cost"] == pytest.approx(0.0245)
assert parsed["cost_breakdown"]["output_cost"] == pytest.approx(0.008)
@pytest.mark.covers("other.provider_wire.anthropic.bare_string_content_item_is_client_error")
@pytest.mark.parametrize(
"text", [pytest.param("what type of file is this?", id="type_word"), pytest.param("hello", id="plain")]
)
def test_anthropic_bare_string_content_item_is_rejected_as_client_error_before_the_wire(
gateway: Gateway, text: str
) -> None:
def respond(request: Request) -> Reply:
raise AssertionError(f"upstream must not be reached: {request.target}")
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model="anthropic/claude-sonnet-4-5-20250929", api_base=wire.url, api_key="synthetic-anthropic-key"
)
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "max_tokens": 16, "timeout": 5, "messages": [{"role": "system", "content": [text]}]},
)
assert response.status_code == 400, response.text
assert wire.drain() == ()

View file

@ -0,0 +1,20 @@
import uuid
from typing import Final
import pytest
from integration._support.client import JSON_OBJECT, Gateway, object_value, string_value
@pytest.mark.covers("quota_management.spend_tracking.spend_calculate.rejects_unpriced_model")
def test_spend_calculate_rejects_unpriced_model_with_400(gateway: Gateway) -> None:
model: Final = f"openrouter/integration-unpriced-{uuid.uuid4().hex}"
response: Final = gateway.request(
"POST",
"/spend/calculate",
{"model": model, "messages": [{"role": "user", "content": "price this request"}]},
)
assert response.status_code == 400, response.text
error: Final = object_value(JSON_OBJECT.validate_json(response.text)["error"])
assert error["type"] == "invalid_request_error", response.text
assert error["param"] == "model", response.text
assert model in string_value(error["message"]), response.text

View file

@ -9,6 +9,7 @@ import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from unittest.mock import Mock
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import BedrockModelInfo
@ -42,7 +43,7 @@ def test_bedrock_completion_with_region_name():
# Pass the client so that the HTTP call will be intercepted.
response = litellm.completion(
model="cohere.command-r-v1:0",
model="bedrock/cohere.command-r-v1:0",
messages=[{"role": "user", "content": "Hello, world!"}],
aws_region_name="us-west-12",
client=client,
@ -98,7 +99,7 @@ def test_bedrock_completion_with_dynamic_authentication_params():
# Pass the client so that the HTTP call will be intercepted.
response = litellm.completion(
model="cohere.command-r-v1:0",
model="bedrock/cohere.command-r-v1:0",
messages=[{"role": "user", "content": "Hello, world!"}],
aws_access_key_id="dynamically_generated_access_key_id",
aws_secret_access_key="dynamically_generated_secret_access_key",
@ -146,7 +147,7 @@ def test_bedrock_completion_with_dynamic_bedrock_runtime_endpoint():
# Pass the client so that the HTTP call will be intercepted.
response = litellm.completion(
model="cohere.command-r-v1:0",
model="bedrock/cohere.command-r-v1:0",
messages=[{"role": "user", "content": "Hello, world!"}],
aws_bedrock_runtime_endpoint="https://my-fake-endpoint.com",
client=client,
@ -179,7 +180,7 @@ class DummyCredentials:
"model",
[
"bedrock/converse/cohere.command-r-v1:0",
"cohere.command-r-v1:0",
"amazon.nova-2-lite-v1:0",
"bedrock/cohere.command-r-v1:0",
"bedrock/invoke/cohere.command-r-v1:0",
],
@ -250,7 +251,7 @@ def test_dynamic_aws_params_propagation(model, param_name, param_value, expected
"finish_reason": "COMPLETE",
}
)
if "converse" in model:
if BedrockModelInfo.get_bedrock_route(model) == "converse":
mock_response.text = json.dumps(
{
"output": {

View file

@ -1284,15 +1284,18 @@ def test_model_group_info():
router = Router(
model_list=[
{
"model_name": "command-r-plus",
"litellm_params": {"model": "cohere.command-r-plus-v1:0"},
"model_name": "nova-2-lite",
"litellm_params": {"model": "bedrock/amazon.nova-2-lite-v1:0"},
}
]
)
response = router.get_model_group_info(model_group="command-r-plus")
response = router.get_model_group_info(model_group="nova-2-lite")
assert response is not None
assert response.model_group == "nova-2-lite"
assert response.providers == ["bedrock"]
assert response.max_input_tokens is not None
def test_consistent_model_id():

View file

@ -25,7 +25,6 @@ from collections.abc import Callable
from pathlib import Path
import pytest
from litellm_proxy_extras.prisma_toolchain import (
DEFAULT_PRISMA_COMMAND_TIMEOUT,
DEFAULT_PRISMA_MIGRATE_DEPLOY_TIMEOUT,
@ -36,14 +35,16 @@ from litellm_proxy_extras.prisma_toolchain import (
heal_incomplete_nodeenv_cache,
node_binary_path,
prisma_bootstrap_timeout,
prisma_command_timeout,
prisma_cli_available,
prisma_command_timeout,
prisma_migrate_deploy_timeout,
resolve_prisma_argv,
run_prisma,
)
from litellm_proxy_extras.utils import ProxyExtrasDBManager
from tests._process_helpers import process_is_gone
REPO_ROOT = Path(__file__).resolve().parents[2]
PROXY_EXTRAS = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras"
@ -280,17 +281,6 @@ def test_migrate_deploy_stops_at_its_own_timeout(
assert elapsed < 30
def _process_is_gone(pid: int, within_seconds: float) -> bool:
deadline = time.monotonic() + within_seconds
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
return True
time.sleep(0.05)
return False
def test_a_timed_out_migrate_deploy_takes_its_process_tree_with_it(
toolchain_env: tuple[Path, Path], monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@ -309,7 +299,7 @@ def test_a_timed_out_migrate_deploy_takes_its_process_tree_with_it(
grandchild_pid = int(pidfile.read_text())
try:
assert len(_deploy_calls(log_path)) == 2
assert _process_is_gone(grandchild_pid, within_seconds=5)
assert process_is_gone(grandchild_pid, within_seconds=5)
finally:
try:
os.kill(grandchild_pid, signal.SIGKILL)

View file

@ -1,3 +1,4 @@
import asyncio
import logging
import re
from unittest.mock import MagicMock
@ -6,8 +7,9 @@ import pytest
import litellm.caching.redis_cache as redis_cache_module
from litellm.caching.caching import Cache
from litellm.caching.caching_handler import _PENDING_CACHE_WRITES
from litellm.caching.redis_cache import RedisCache, _RedisTimeoutLogThrottle
from litellm.types.caching import LiteLLMCacheType, SemanticCacheScope
from litellm.types.caching import EMBEDDING_CACHE_FORMAT_VERSION, LiteLLMCacheType, SemanticCacheScope
from litellm.types.utils import Embedding, EmbeddingResponse, Usage
@ -278,3 +280,112 @@ def test_exact_cache_key_includes_anthropic_messages_params(anthropic_param):
assert baseline != cache.get_cache_key(
model="claude-sonnet-4-5", messages=messages, **anthropic_param
)
@pytest.mark.asyncio
async def test_embedding_cache_skips_write_when_one_input_yields_many_embeddings(monkeypatch):
"""A cross-encoder behind /embeddings returns one score per document for a single
input string; caching data[0] per input would make the second call return 1 score."""
import litellm
from litellm import CustomLLM
class ScoreEveryDocument(CustomLLM):
provider_calls: int = 0
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_calls += 1
return EmbeddingResponse(
model=model,
data=[Embedding(embedding=[float(i)], index=i, object="embedding") for i in range(5)],
)
scorer = ScoreEveryDocument()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "score-every-doc", "custom_handler": scorer}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "score-every-doc"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "score-every-doc"])
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
batch = '{"query": "q", "documents": ["a", "b", "c", "d", "e"]}'
first = await litellm.aembedding(model="score-every-doc/m", input=[batch])
await asyncio.gather(*_PENDING_CACHE_WRITES)
second = await litellm.aembedding(model="score-every-doc/m", input=[batch])
assert scorer.provider_calls == 2
assert [len(first.data), len(second.data)] == [5, 5]
@pytest.mark.asyncio
async def test_embedding_cache_refetches_entries_written_without_format_version(monkeypatch):
import litellm
from litellm import CustomLLM
class EmbedLength(CustomLLM):
provider_calls: int = 0
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_calls += 1
return EmbeddingResponse(
model=model,
data=[
Embedding(embedding=[float(len(text))], index=idx, object="embedding")
for idx, text in enumerate(input)
],
)
embedder = EmbedLength()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "embed-length", "custom_handler": embedder}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "embed-length"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "embed-length"])
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
await litellm.aembedding(model="embed-length/m", input=["abcd"])
await asyncio.gather(*_PENDING_CACHE_WRITES)
store = litellm.cache.cache.cache_dict
stored = [entry["response"] for entry in store.values()]
assert [entry["format_version"] for entry in stored] == [EMBEDDING_CACHE_FORMAT_VERSION], stored
legacy_store = {
key: {
**entry,
"response": {
field: value
for field, value in {**entry["response"], "embedding": [-1.0]}.items()
if field != "format_version"
},
}
for key, entry in store.items()
}
monkeypatch.setattr(litellm.cache.cache, "cache_dict", legacy_store)
refetched = await litellm.aembedding(model="embed-length/m", input=["abcd"])
assert embedder.provider_calls == 2, "an entry written without format_version must be a cache miss"
assert [item["embedding"] for item in refetched.data] == [[4.0]]
@pytest.mark.asyncio
async def test_embedding_cache_serves_base64_string_embeddings_on_repeat(monkeypatch):
import litellm
from litellm import CustomLLM
class Base64Embedder(CustomLLM):
provider_calls: int = 0
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_calls += 1
return EmbeddingResponse(
model=model,
data=[Embedding(embedding="AACAPwAAAEA=", index=idx, object="embedding") for idx, _ in enumerate(input)],
)
embedder = Base64Embedder()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "embed-b64", "custom_handler": embedder}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "embed-b64"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "embed-b64"])
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
first = await litellm.aembedding(model="embed-b64/m", input=["abcd"])
await asyncio.gather(*_PENDING_CACHE_WRITES)
second = await litellm.aembedding(model="embed-b64/m", input=["abcd"])
assert embedder.provider_calls == 1, "a string embedding written to the cache must be served on repeat"
assert [item["embedding"] for item in second.data] == [item["embedding"] for item in first.data] == ["AACAPwAAAEA="]

View file

@ -11,7 +11,7 @@ from fastapi.testclient import TestClient
from datetime import datetime
from unittest.mock import AsyncMock
from litellm.caching.caching_handler import LLMCachingHandler
from litellm.caching.caching_handler import _PENDING_CACHE_WRITES, LLMCachingHandler
@pytest.mark.asyncio
@ -780,3 +780,46 @@ async def test_agentic_loop_followup_cache_hit_with_converted_stream_marker_repl
assert hit.cached_result.choices[0].message.content == "done"
logging_obj.handle_sync_success_callbacks_for_async_calls.assert_called_once()
assert logging_obj.handle_sync_success_callbacks_for_async_calls.call_args.kwargs["cache_hit"] is True
@pytest.mark.asyncio
async def test_partial_embedding_cache_hit_sends_only_misses_and_keeps_input_order(monkeypatch):
import litellm
from litellm import CustomLLM
from litellm.caching.caching import Cache
from litellm.types.utils import Embedding, EmbeddingResponse
class RecordingEmbedder(CustomLLM):
provider_inputs: tuple[tuple[str, ...], ...] = ()
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_inputs = (*self.provider_inputs, tuple(input))
return EmbeddingResponse(
model=model,
data=[
Embedding(embedding=[float(len(text))], index=idx, object="embedding")
for idx, text in enumerate(input)
],
)
embedder = RecordingEmbedder()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "recording-embedder", "custom_handler": embedder}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "recording-embedder"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "recording-embedder"])
monkeypatch.setattr(litellm, "cache", Cache(type="local"))
await litellm.aembedding(model="recording-embedder/m", input=["aa", "bbbb"])
await asyncio.gather(*_PENDING_CACHE_WRITES)
mixed_input = ["c", "aa", "ddd", "bbbb", "eeeee"]
response = await litellm.aembedding(model="recording-embedder/m", input=mixed_input)
await asyncio.gather(*_PENDING_CACHE_WRITES)
assert embedder.provider_inputs == (("aa", "bbbb"), ("c", "ddd", "eeeee")), embedder.provider_inputs
assert [item["index"] for item in response.data] == [0, 1, 2, 3, 4]
assert [item["embedding"] for item in response.data] == [[float(len(text))] for text in mixed_input]
assert response._hidden_params["cache_hit"] is True, "a partial hit must still be reported as a cache hit"
repeat = await litellm.aembedding(model="recording-embedder/m", input=mixed_input)
assert len(embedder.provider_inputs) == 2, embedder.provider_inputs
assert [item["embedding"] for item in repeat.data] == [[float(len(text))] for text in mixed_input]

View file

@ -1290,6 +1290,50 @@ def test_operation_exception_log_event_always_carries_required_pair():
assert ExceptionEvent.STACKTRACE not in attributes
def test_operation_exception_log_event_records_without_the_events_api():
"""Recording must not import the Events API modules (removed upstream in 1.44.0);
the SDK record path still exports."""
import importlib
import sys
from unittest.mock import patch
from opentelemetry._logs.severity import SeverityNumber
from opentelemetry.sdk._logs.export import InMemoryLogExporter
from opentelemetry.trace import INVALID_SPAN_CONTEXT
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
plumbing = ("litellm.integrations.otel.plumbing.events", "litellm.integrations.otel.plumbing.providers")
without_events_api = {
**{name: module for name, module in sys.modules.items() if name not in plumbing},
"opentelemetry._events": None,
"opentelemetry.sdk._events": None,
}
with patch.dict(sys.modules, without_events_api, clear=True):
events_mod = importlib.import_module(plumbing[0])
providers_mod = importlib.import_module(plumbing[1])
log_exporter = InMemoryLogExporter()
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=True)
logger_provider = providers_mod.build_logger_provider(cfg, log_exporter=log_exporter)
recorder = events_mod.GenAIEventRecorder(providers_mod.get_event_logger(logger_provider))
recorder.record_operation_exception(
span_context=INVALID_SPAN_CONTEXT,
error_type="RateLimitError",
message="rate limited",
stack_trace=None,
timestamp_ns=None,
)
(log,) = log_exporter.get_finished_logs()
record = log.log_record
assert record.attributes[GenAIEvent.NAME_KEY] == GenAIEvent.OPERATION_EXCEPTION
assert record.attributes[ExceptionEvent.TYPE] == "RateLimitError"
assert record.attributes[ExceptionEvent.MESSAGE] == "rate limited"
assert record.severity_number == SeverityNumber.WARN
assert record.timestamp is not None
def test_operation_exception_log_event_not_emitted_on_success():
engine, span_exporter, log_exporter = _engine_with_event_recorder()
engine.emit(SpanRole.LLM_CALL, _llm_call_data(None))
@ -1425,6 +1469,87 @@ def test_genai_mapper_guardrail_cost_in_spend_attr():
assert LiteLLM.GUARDRAIL_COST_IN_SPEND not in GenAIMapper().map(GuardrailSpanData.from_logging_entry(billed))
def _sampled_span_context():
from opentelemetry.trace import SpanContext, TraceFlags, TraceState
return SpanContext(
trace_id=0x0AF7651916CD43DD8448EB211C80319C,
span_id=0x00F067AA0BA902B7,
is_remote=False,
trace_flags=TraceFlags(TraceFlags.SAMPLED),
trace_state=TraceState(),
)
def test_operation_exception_log_event_exports_through_console_exporter():
"""The emitted record serializes through a real SDK exporter: the console
exporter only handles SDK-shaped records (``to_json`` plus a resource), so
an API-shaped record crashed the export under the repo's pinned OTel."""
import io
import json as json_mod
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import ConsoleLogExporter, SimpleLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
from litellm.integrations.otel.plumbing.events import GenAIEventRecorder
out = io.StringIO()
logger_provider = LoggerProvider(resource=Resource.create({"service.name": "otel-event-test"}))
logger_provider.add_log_record_processor(SimpleLogRecordProcessor(ConsoleLogExporter(out=out)))
recorder = GenAIEventRecorder(providers.get_event_logger(logger_provider), logger_provider.resource)
recorder.record_operation_exception(
span_context=_sampled_span_context(),
error_type="RateLimitError",
message="rate limited",
stack_trace=None,
timestamp_ns=None,
)
exported = json_mod.loads(out.getvalue())
assert exported["attributes"][GenAIEvent.NAME_KEY] == GenAIEvent.OPERATION_EXCEPTION
assert exported["attributes"][ExceptionEvent.TYPE] == "RateLimitError"
assert exported["attributes"][ExceptionEvent.MESSAGE] == "rate limited"
assert exported["body"] == "rate limited"
assert exported["resource"]["attributes"]["service.name"] == "otel-event-test"
def test_operation_exception_log_event_encodes_for_otlp():
"""The OTLP log encoder reads ``log_record.resource`` and rejects a None
body on the pinned OTel line, so the event must encode into a real
ExportLogsServiceRequest, not only land in an in-memory exporter."""
from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs
from opentelemetry.sdk._logs.export import InMemoryLogExporter
from litellm.integrations.otel.model.semconv import GenAIEvent
from litellm.integrations.otel.plumbing.events import GenAIEventRecorder
log_exporter = InMemoryLogExporter()
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=True)
logger_provider = providers.build_logger_provider(cfg, log_exporter=log_exporter)
recorder = GenAIEventRecorder(providers.get_event_logger(logger_provider), logger_provider.resource)
recorder.record_operation_exception(
span_context=_sampled_span_context(),
error_type="RateLimitError",
message="rate limited",
stack_trace=None,
timestamp_ns=None,
)
request = encode_logs(log_exporter.get_finished_logs())
(resource_logs,) = request.resource_logs
(scope_logs,) = resource_logs.scope_logs
(encoded,) = scope_logs.log_records
encoded_attrs = {a.key: a.value.string_value for a in encoded.attributes}
assert encoded_attrs[GenAIEvent.NAME_KEY] == GenAIEvent.OPERATION_EXCEPTION
assert encoded.body.string_value == "rate limited"
resource_attrs = {a.key: a.value.string_value for a in resource_logs.resource.attributes}
assert resource_attrs["service.name"] == logger_provider.resource.attributes["service.name"]
def _isolate_v2_otlp_tls_env(monkeypatch: pytest.MonkeyPatch) -> None:
for key in (
"SSL_VERIFY",

View file

@ -4,7 +4,6 @@ import json
import os
import sys
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
@ -356,6 +355,20 @@ def test_get_file_ids_from_messages_file_field_not_dict():
assert get_file_ids_from_messages(messages) == []
def test_get_file_ids_from_messages_skips_bare_string_content_items():
messages = [
{
"role": "user",
"content": [
"what type of file is this?",
{"type": "file", "file": {"file_id": "file-abc"}},
],
}
]
assert get_file_ids_from_messages(messages) == ["file-abc"]
def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks():
"""`update_messages_with_model_file_ids` is also called on user content
before provider dispatch. It must tolerate non-OpenAI file blocks the same

View file

@ -0,0 +1,231 @@
import httpx
import pytest
import litellm
from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.error_normalization import normalize_error
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.types.router import RouterErrors
_RESPONSE = httpx.Response(status_code=500, request=httpx.Request("POST", "https://example.invalid"))
def _proxy_exc(message: str, error_type: str, code: int) -> ProxyException:
return ProxyException(message=message, type=error_type, param=None, code=code)
@pytest.mark.parametrize(
("messages", "expected"),
[
(
(
_proxy_exc("Rate limit exceeded for team X. Reset at 10:01", "rate_limit_error", 429),
_proxy_exc("Rate limit exceeded for team Y. Reset at 10:02", "rate_limit_error", 429),
),
"429_RATE_LIMIT_EXCEEDED",
),
(
(
litellm.BudgetExceededError(current_cost=3501.85, max_budget=3500),
_proxy_exc(
"User=abc, Current cost=1000.03, Max budget=1000", ProxyErrorTypes.budget_exceeded.value, 400
),
litellm.RateLimitError(
"budget",
llm_provider="openai",
model="gpt",
rate_limit_type=litellm.exceptions.RateLimitType.BUDGET,
),
),
"429_BUDGET_EXCEEDED",
),
(
(
_proxy_exc("Token Expired", ProxyErrorTypes.expired_key.value, 401),
_proxy_exc("Malformed API Key", ProxyErrorTypes.auth_error.value, 401),
litellm.AuthenticationError("Signature verification failed", llm_provider="azure", model="gpt"),
),
"401_AUTHENTICATION_FAILED",
),
(
(
_proxy_exc("No team has access to gpt-5.5-mini", ProxyErrorTypes.team_model_access_denied.value, 401),
_proxy_exc("key not allowed to access claude", ProxyErrorTypes.key_model_access_denied.value, 401),
ValueError(
"Not allowed to access model due to tags configuration. Passed model=gpt-5.5 and tags=['team-a']"
),
),
"403_MODEL_ACCESS_DENIED",
),
(
(
_proxy_exc("Missing required parameter: messages", ProxyErrorTypes.bad_request_error.value, 400),
litellm.BadRequestError("Missing required parameter: input", llm_provider="openai", model="gpt"),
),
"400_MISSING_REQUIRED_PARAMETER",
),
(
(
litellm.ContextWindowExceededError(
"1002823 tokens > 1000000 maximum", model="g", llm_provider="vertex"
),
litellm.BadRequestError("Input is too long for requested model", llm_provider="anthropic", model="c"),
),
"400_CONTEXT_WINDOW_EXCEEDED",
),
(
(
litellm.NotFoundError("Response id xxx not found", llm_provider="openai", model="gpt"),
_proxy_exc("No vector store found with id abc", ProxyErrorTypes.not_found_error.value, 404),
),
"404_RESOURCE_NOT_FOUND",
),
(
(
litellm.APIConnectionError("Connection error", llm_provider="openai", model="gpt"),
litellm.InternalServerError("TransferEncodingError", llm_provider="openai", model="gpt"),
litellm.APIError(500, "Response payload is not completed", llm_provider="openai", model="gpt"),
httpx.RemoteProtocolError(
"peer closed connection without sending complete message body (incomplete chunked read)"
),
),
"500_PROVIDER_CONNECTION_ERROR",
),
(
(
litellm.ServiceUnavailableError("server_is_overloaded", llm_provider="anthropic", model="c"),
litellm.InternalServerError(
"Bedrock is unable to process your request", llm_provider="bedrock", model="c"
),
litellm.APIError(529, "Overloaded", llm_provider="anthropic", model="c"),
),
"503_PROVIDER_OVERLOADED",
),
(
(
litellm.InternalServerError(
"The server had an error while processing your request", llm_provider="openai", model="gpt"
),
litellm.APIError(500, "server_error", llm_provider="openai", model="gpt"),
),
"500_PROVIDER_INTERNAL_ERROR",
),
(
(
_proxy_exc("No fallback model group found for gpt-5.6", "internal_server_error", 500),
_proxy_exc("No fallback model group found for claude-46-sonnet", "internal_server_error", 500),
),
"500_ROUTER_NO_FALLBACK",
),
(
(
_proxy_exc("Error doing the fallback: RateLimitError", "internal_server_error", 500),
MidStreamFallbackError(
"stream died", model="gpt", llm_provider="openai", original_exception=ValueError("boom")
),
),
"500_ROUTER_FALLBACK_FAILURE",
),
(
(
TypeError("cannot pickle '_thread.RLock' object"),
RuntimeError("dictionary changed size during iteration"),
TypeError("'NoneType' object is not iterable"),
),
"500_INTERNAL_STATE_ERROR",
),
(
(
litellm.Timeout("Timeout on reading data from socket", model="gpt", llm_provider="openai"),
litellm.APIError(504, "Request timed out", llm_provider="openai", model="gpt"),
),
"408_UPSTREAM_TIMEOUT",
),
(
(
_proxy_exc("500: Upstream passthrough request failed", "internal_server_error", 500),
_proxy_exc("503: Upstream passthrough request failed", "internal_server_error", 503),
),
"500_UPSTREAM_PASSTHROUGH",
),
(
(
_proxy_exc("OCR is not supported for provider openai", "internal_server_error", 500),
NotImplementedError("rerank"),
),
"500_UNSUPPORTED_OPERATION",
),
],
)
def test_variants_of_one_failure_share_a_normalized_error(messages: tuple[Exception, ...], expected: str) -> None:
normalized = {StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] for exc in messages}
assert normalized == {expected}
def test_router_no_healthy_deployment_wording_clusters_as_no_healthy_deployments() -> None:
for message in (RouterErrors.no_healthy_deployments.value, "No healthy deployments found."):
exc = litellm.BadRequestError(message, llm_provider="openai", model="gpt-4o")
assert normalize_error(exc, "400", message) == "429_NO_HEALTHY_DEPLOYMENTS", message
def test_provider_budget_routing_wording_clusters_as_budget_exceeded() -> None:
message = RouterErrors.no_deployments_with_provider_budget_routing.value
exc = litellm.BadRequestError(message, llm_provider="openai", model="gpt-4o")
assert normalize_error(exc, "400", message) == "429_BUDGET_EXCEEDED"
def test_router_fallback_wording_does_not_hide_the_wrapped_exception_class() -> None:
provider_message = "litellm.AuthenticationError: OpenAIException - Incorrect API key provided"
exc = litellm.AuthenticationError(
provider_message + "\nNo fallback model group found for lookup_groups=['x']",
llm_provider="openai",
model="gpt",
)
assert normalize_error(exc, "401", str(exc)) == "401_AUTHENTICATION_FAILED"
wrapped = litellm.AuthenticationError(
"Error doing the fallback: " + provider_message, llm_provider="openai", model="gpt"
)
assert normalize_error(wrapped, "401", str(wrapped)) == "401_AUTHENTICATION_FAILED"
def test_parameter_length_error_is_not_a_context_window_error() -> None:
exc = litellm.BadRequestError("string too long: 'user' max 64 chars", llm_provider="openai", model="gpt")
assert StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] == "400_INVALID_REQUEST"
def test_no_exception_has_no_normalized_error() -> None:
assert StandardLoggingPayloadSetup.get_error_information(None)["normalized_error"] is None
def test_unknown_exception_falls_back_to_status_then_unclassified() -> None:
assert normalize_error(Exception("x"), "429", "x") == "429_RATE_LIMIT_EXCEEDED"
assert normalize_error(Exception("x"), "", "x") == "UNCLASSIFIED"
def test_budget_exceeded_error_with_custom_wording_is_still_a_budget_error() -> None:
exc = litellm.BudgetExceededError(current_cost=2.0, max_budget=1.0, message="Spending cap reached for key")
assert StandardLoggingPayloadSetup.get_error_information(exc)["normalized_error"] == "429_BUDGET_EXCEEDED"
def test_every_model_access_denied_proxy_type_shares_one_cluster() -> None:
access_denied_types = tuple(t for t in ProxyErrorTypes if t.value.endswith("_model_access_denied"))
assert len(access_denied_types) >= 6, access_denied_types
codes = {normalize_error(_proxy_exc("denied", t.value, 403), "403", "denied") for t in access_denied_types}
assert codes == {"403_MODEL_ACCESS_DENIED"}, codes
def test_non_string_type_attribute_falls_through_to_status() -> None:
class _OddType(Exception):
type = {"kind": "odd"}
assert normalize_error(_OddType("odd"), "500", "odd") == "500_PROVIDER_INTERNAL_ERROR"
def test_normalized_error_never_embeds_dynamic_parts() -> None:
exc = _proxy_exc(
"No team has access to anthropic.claude-sonnet-4-5", ProxyErrorTypes.team_model_access_denied.value, 401
)
info = StandardLoggingPayloadSetup.get_error_information(exc)
assert info["error_message"] == "No team has access to anthropic.claude-sonnet-4-5"
assert "claude" not in (info["normalized_error"] or "")

View file

@ -14,6 +14,7 @@ import json
import os
import sys
from types import SimpleNamespace
from typing import Final
from unittest.mock import patch
import pytest
@ -2275,3 +2276,72 @@ def test_create_anthropic_model_list_response_lists_ids_as_told():
assert (gpt["id"], gpt["display_name"], gpt["max_input_tokens"]) == ("claude-router-gpt-4o[1m]", "GPT 4o", 1000000)
assert (haiku["id"], haiku["display_name"]) == ("claude-haiku-4-5", "claude-haiku-4-5")
assert (response["first_id"], response["last_id"]) == ("claude-router-gpt-4o[1m]", "claude-haiku-4-5")
class TestMalformedContentListItems:
@pytest.mark.parametrize(
"content",
[
pytest.param(["what type of file is this?"], id="string_containing_type"),
pytest.param(["how do I set cache_control?"], id="string_containing_cache_control"),
pytest.param([None], id="none_item"),
pytest.param([5], id="int_item"),
pytest.param([["nested"]], id="list_item"),
],
)
def test_beta_headers_resolve_for_non_dict_content_items(self, content: list[object]) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
config: Final = AnthropicModelInfo()
messages: Final = [{"role": "user", "content": content}]
headers: Final = config.validate_environment(
headers={},
model="claude-sonnet-4-5",
messages=messages,
optional_params={},
litellm_params={},
api_key=FAKE_REGULAR_KEY,
)
assert headers["x-api-key"] == FAKE_REGULAR_KEY
assert config.is_cache_control_set(messages) is False
assert config.is_pdf_used(messages) is False
def test_real_content_parts_still_set_their_beta_headers(self) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
config: Final = AnthropicModelInfo()
assert config.is_pdf_used([{"role": "user", "content": [{"type": "image", "source": {}}]}]) is True
assert config.is_pdf_used([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]) is False
assert (
config.is_cache_control_set(
[
{
"role": "user",
"content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}],
}
]
)
is True
)
def test_mixed_list_keeps_detecting_the_valid_part(self) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
config: Final = AnthropicModelInfo()
messages: Final = [{"role": "user", "content": ["what type of file is this?", {"type": "image", "source": {}}]}]
assert config.is_pdf_used(messages) is True
def test_litellm_completion_rejects_bare_string_content_item_as_bad_request(self) -> None:
import litellm
with pytest.raises(litellm.BadRequestError):
litellm.completion(
model="anthropic/claude-haiku-4-5-20251001",
messages=[{"role": "user", "content": ["what type of file is this?"]}],
api_key=FAKE_REGULAR_KEY,
max_tokens=5,
)

View file

@ -2,14 +2,15 @@ import json
import os
import signal
import sys
import time
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Optional
from typing import Optional
import pytest
from tests._process_helpers import process_is_gone
DB_ENV_KEYS = (
"IAM_TOKEN_DB_AUTH",
"AZURE_POSTGRESQL_AUTH",
@ -35,14 +36,6 @@ DB_ENV_KEYS = (
_db_env_snapshot_key = pytest.StashKey[dict[str, Optional[str]]]()
def _is_zombie(pid: int) -> bool:
try:
stat: Final = Path(f"/proc/{pid}/stat").read_text()
except OSError:
return False
return stat.rpartition(")")[2].split()[0] == "Z"
def _db_env_snapshot() -> dict[str, Optional[str]]:
return {key: os.environ.get(key) for key in DB_ENV_KEYS}
@ -130,24 +123,7 @@ class FakePrismaCli:
return [json.loads(line) for line in self.calls_file.read_text().splitlines()]
def grandchild_is_gone(self, within_seconds: float) -> bool:
pid: Final = int(self.grandchild_pidfile.read_text())
deadline: Final = time.monotonic() + within_seconds
while time.monotonic() < deadline:
if os.name != "nt":
try:
reaped_pid, _ = os.waitpid(pid, os.WNOHANG)
if reaped_pid == pid:
return True
except ChildProcessError:
pass
try:
os.kill(pid, 0)
except ProcessLookupError:
return True
if _is_zombie(pid):
return True
time.sleep(0.05)
return False
return process_is_gone(int(self.grandchild_pidfile.read_text()), within_seconds=within_seconds)
@pytest.fixture

View file

@ -2,6 +2,7 @@ import asyncio
import hashlib
import json
import logging
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Final
@ -37,6 +38,7 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import (
ui_view_users,
)
from litellm.proxy.proxy_server import app
from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains
from tests.test_litellm.proxy.management_endpoints.jwt_key_mapping_doubles import (
CascadingJWTMappingTable,
JWTMappingRow,
@ -119,6 +121,70 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker):
)
UserWhereCondition = InsensitiveContains | Sequence[Mapping[str, InsensitiveContains]]
def _matches_user_where(row: LiteLLM_UserTableFiltered, where: Mapping[str, UserWhereCondition]) -> bool:
def matches(field: str, condition: UserWhereCondition) -> bool:
if not isinstance(condition, Mapping):
return any(_matches_user_where(row, branch) for branch in condition)
value: Final = {"user_id": row.user_id, "user_email": row.user_email}[field]
return value is not None and condition["contains"].lower() in value.lower()
return all(matches(field, condition) for field, condition in where.items())
@pytest.mark.parametrize(
"params, expected_user_ids",
[
({"search": "SVC"}, ["svc-bot"]),
({"search": "ali"}, ["alice-admin"]),
({"search": "example.com"}, ["alice-admin"]),
({"search": "admin"}, ["alice-admin"]),
({"user_email": "svc"}, []),
({"user_id": "svc"}, ["svc-bot"]),
({"search": "ali", "user_id": "svc"}, []),
],
)
def test_ui_view_users_search_matches_user_id_or_email(
mocker: MockerFixture, params: Mapping[str, str], expected_user_ids: list[str]
):
"""
search= returns users whose user_id or user_email contains the value (case-insensitive),
including users with no email; user_id=/user_email= keep filtering a single field and AND with search.
"""
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
users = (
LiteLLM_UserTableFiltered(user_id="alice-admin", user_email="alice@example.com"),
LiteLLM_UserTableFiltered(user_id="svc-bot", user_email=None),
LiteLLM_UserTableFiltered(user_id="bob", user_email="bob@corp.io"),
)
async def mock_find_many(*, where: Mapping[str, UserWhereCondition], **_: object):
return [user for user in users if _matches_user_where(user, where)]
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_many = mock_find_many
mocker.patch( # test-quality-ok: endpoint reads settings via module global; same seam as sibling tests
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached",
return_value={},
)
mocker.patch( # test-quality-ok: endpoint reads prisma_client via module global; same seam as sibling tests
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
response = client.get("/user/filter/ui", params=params)
finally:
app.dependency_overrides.pop(user_api_key_auth, None)
assert response.status_code == 200, response.text
assert [user["user_id"] for user in response.json()] == expected_user_ids
@pytest.mark.asyncio
async def test_ui_view_users_org_admin_filtered_by_org(mocker):
"""

View file

@ -3089,6 +3089,50 @@ async def test_update_key_by_alias_only(monkeypatch):
assert result["key"] == hashed_token
@pytest.mark.asyncio
async def test_update_key_changed_alias_must_match_key_alias_pattern(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import (
update_key_fn,
)
monkeypatch.setattr(litellm, "key_alias_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
hashed_token = "0d62f396c1317066f55a96086517047c737087c61eb2bf016b72e6298927b15b"
key_in_db = LiteLLM_VerificationToken(token=hashed_token, key_alias="Legacy Alias", user_id="test-user")
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_in_db)
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[key_in_db])
mock_prisma_client.db.litellm_verificationtoken.find_first = AsyncMock(return_value=None)
mock_prisma_client.update_data = AsyncMock(return_value={"data": {"max_budget": 50.0}})
_setup_update_key_mocks(monkeypatch, mock_prisma_client)
user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user"
)
with pytest.raises(ProxyException) as exc_info:
await update_key_fn(
request=MagicMock(),
data=UpdateKeyRequest(key=hashed_token, key_alias="Prod Key"),
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
assert str(exc_info.value.code) == "400"
assert "key_alias_pattern" in str(exc_info.value.message)
mock_prisma_client.update_data.assert_not_awaited()
with patch(
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
return_value=None,
):
await update_key_fn(
request=MagicMock(),
data=UpdateKeyRequest(key=hashed_token, key_alias="Legacy Alias", max_budget=50.0),
user_api_key_dict=user_api_key_dict,
litellm_changed_by=None,
)
mock_prisma_client.update_data.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_key_by_alias_not_found_returns_404(monkeypatch):
"""
@ -10562,6 +10606,10 @@ class TestValidateKeyAliasFormat:
def reset_key_alias_flag(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "enable_key_alias_format_validation", False)
@pytest.fixture(autouse=True)
def reset_key_alias_pattern(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "key_alias_pattern", None)
def test_validation_skipped_when_flag_disabled(self):
"""When enable_key_alias_format_validation is False (default), no charset/length validation occurs."""
from litellm.proxy.management_endpoints.key_management_endpoints import (
@ -10644,6 +10692,67 @@ class TestValidateKeyAliasFormat:
assert str(exc.value.code) == "400"
assert "Invalid key_alias format" in str(exc.value.message)
def test_configured_pattern_applies_with_flag_off(self, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
monkeypatch.setattr(litellm, "key_alias_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
with pytest.raises(ProxyException) as exc:
_validate_key_alias_format("Prod Key")
assert str(exc.value.code) == "400"
assert exc.value.param == "key_alias"
assert "key_alias_pattern" in str(exc.value.message)
assert r"^[a-z0-9]+(-[a-z0-9]+)*$" in str(exc.value.message)
assert _validate_key_alias_format("prod-key-001") is None
assert _validate_key_alias_format(None) is None
def test_configured_pattern_must_match_the_whole_alias(self, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
monkeypatch.setattr(litellm, "key_alias_pattern", r"team-[a-z]+")
_validate_key_alias_format("team-search")
for partial_match in ("team-search-2", "xteam-search"):
with pytest.raises(ProxyException):
_validate_key_alias_format(partial_match)
def test_configured_pattern_replaces_the_builtin_rule(self, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
monkeypatch.setattr(litellm, "enable_key_alias_format_validation", True)
monkeypatch.setattr(litellm, "key_alias_pattern", r"^[a-z ]+$")
_validate_key_alias_format("alias with spaces")
with pytest.raises(ProxyException) as exc:
_validate_key_alias_format("Uppercase")
assert "key_alias_pattern" in str(exc.value.message)
def test_configured_pattern_keeps_the_baseline_safety_check(self, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
monkeypatch.setattr(litellm, "key_alias_pattern", r".*")
with pytest.raises(ProxyException) as exc:
_validate_key_alias_format("../../../other-app/creds")
assert str(exc.value.code) == "400"
assert "key_alias_pattern" not in str(exc.value.message)
def test_configured_pattern_bounds_the_alias_length(self, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.management_endpoints.key_management_endpoints import (
_validate_key_alias_format,
)
monkeypatch.setattr(litellm, "key_alias_pattern", r"^[a-z]+$")
_validate_key_alias_format("a" * 255)
with pytest.raises(ProxyException) as exc:
_validate_key_alias_format("a" * 256)
assert str(exc.value.code) == "400"
assert "at most 255 characters" in str(exc.value.message)
@pytest.mark.asyncio
async def test_check_org_key_limits_on_update_within_bounds():
@ -12734,6 +12843,55 @@ async def test_execute_virtual_key_regeneration_rejects_over_limit_duration(monk
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 0
@pytest.mark.asyncio
async def test_execute_virtual_key_regeneration_changed_alias_must_match_key_alias_pattern(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
_execute_virtual_key_regeneration,
)
monkeypatch.setattr(litellm, "key_alias_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
mock_prisma_client = _make_regenerate_mock_prisma()
with (
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.get_new_token",
new_callable=AsyncMock,
return_value="sk-newtoken1234ab12",
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._insert_deprecated_key",
new_callable=AsyncMock,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._persist_deleted_verification_tokens",
new_callable=AsyncMock,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
new_callable=AsyncMock,
),
):
with pytest.raises(ProxyException) as exc_info:
await _execute_virtual_key_regeneration(
prisma_client=mock_prisma_client,
key_in_db=_make_regenerate_existing_key(),
hashed_api_key="abc123",
key="abc123",
data=RegenerateKeyRequest(key_alias="Regenerated Key"),
user_api_key_dict=_make_regenerate_user_api_key_dict(),
litellm_changed_by=None,
user_api_key_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
)
assert str(exc_info.value.code) == "400"
assert exc_info.value.param == "key_alias"
assert r"^[a-z0-9]+(-[a-z0-9]+)*$" in str(exc_info.value.message)
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 0
@pytest.mark.asyncio
async def test_execute_virtual_key_regeneration_allows_within_limit_duration(monkeypatch):
"""Regenerate must accept durations within upperbound_key_generate_params.duration."""

View file

@ -257,6 +257,8 @@ from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.proxy._types import (
LitellmUserRoles,
Member,
ProxyException,
SpendCalculateRequest,
SpendLogsPayload,
UserAPIKeyAuth,
)
@ -7835,3 +7837,18 @@ def test_ui_view_request_response_internal_user_missing_row_forbidden(client, mo
assert custom_logger.requested_ids == []
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_calculate_spend_unpriced_model_returns_400():
model = "openrouter/unit-test-unpriced-model"
with patch("litellm.proxy.proxy_server.llm_router", None):
with pytest.raises(ProxyException) as exc_info:
await spend_management_endpoints.calculate_spend(
SpendCalculateRequest(model=model, messages=[{"role": "user", "content": "hi"}])
)
assert exc_info.value.code == "400"
assert exc_info.value.type == "invalid_request_error"
assert exc_info.value.param == "model"
assert model in exc_info.value.message

View file

@ -23,8 +23,10 @@ import (which raises on a non-postgres ``DATABASE_URL`` scheme and can mint an
RDS IAM token when ``IAM_TOKEN_DB_AUTH`` is set).
"""
import json
import os
import sys
from typing import Final
# Importing ``litellm.proxy.proxy_server`` runs its module-level setup, which
# reads ``DATABASE_URL`` (Prisma) and ``LITELLM_MASTER_KEY``. Tier-zero CI
@ -49,17 +51,10 @@ _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..",
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from backend.routes.allowlist import (
BACKEND_EXACT_PATHS,
BACKEND_MOUNT_PATHS,
BACKEND_PATH_PREFIXES,
)
from gateway.routes.allowlist import (
GATEWAY_EXACT_PATHS,
GATEWAY_MOUNT_PATHS,
GATEWAY_PATH_PREFIXES,
)
from backend.routes.allowlist import BACKEND_MOUNT_PATHS
from gateway.routes.allowlist import GATEWAY_MOUNT_PATHS
from litellm.proxy.proxy_server import app
from tests.test_litellm_rust.support.child_interpreter import run_child_interpreter
for _key, _previous in _PRE_EXISTING_ENV.items():
if _previous is None:
@ -87,40 +82,69 @@ for _key, _previous in _PRE_DB_ENV.items():
os.environ[_key] = _previous
def _component_paths(routes, exact_paths, path_prefixes) -> set[str]:
"""Reproduce ``gateway.main._is_gateway_route`` / ``backend.main._is_backend_route``."""
out: set[str] = set()
for route in routes:
if isinstance(route, Mount):
continue
path = getattr(route, "path", None)
if path is None:
continue
if path in exact_paths or any(path.startswith(p) for p in path_prefixes):
out.add(path)
return out
_COVERAGE_PROBE: Final = """
import json, os, sys
sys.path.insert(0, os.environ["LITELLM_COMPONENT_ALLOWLIST_REPO_ROOT"])
from fastapi.routing import Mount
from backend.routes.allowlist import BACKEND_EXACT_PATHS, BACKEND_PATH_PREFIXES
from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES
from litellm.proxy._lazy_features import loaded_lazy_modules
from litellm.proxy.proxy_server import app
all_paths = {
r.path for r in app.router.routes
if not isinstance(r, Mount) and getattr(r, "path", None) is not None
}
def covered(exact, prefixes):
return {p for p in all_paths if p in exact or any(p.startswith(x) for x in prefixes)}
json.dump({
"lazy_loaded": sorted(loaded_lazy_modules(app)),
"route_count": len(all_paths),
"uncovered": sorted(all_paths - (
covered(GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES)
| covered(BACKEND_EXACT_PATHS, BACKEND_PATH_PREFIXES)
)),
}, sys.stdout)
"""
def test_gateway_plus_backend_covers_full_app():
"""Every route on the proxy app must be served by gateway or backend."""
all_paths = {
getattr(r, "path")
for r in app.router.routes
if not isinstance(r, Mount) and getattr(r, "path", None) is not None
}
gateway_paths = _component_paths(
app.router.routes, GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES
"""Every route on the proxy app must be served by gateway or backend.
``gateway.main`` and ``backend.main`` trim the route table once, inside the
lifespan, so the set this has to cover is the one registered at startup. A
lazy feature appends its router on demand, after that trim, and whether a
sibling test in the same xdist worker has triggered one is not something
this test can control. Measuring in a fresh interpreter is what makes the
route table deterministic; nothing is subtracted, so every route the trim
will actually see stays in the assertion.
"""
env: Final = {**os.environ, "LITELLM_COMPONENT_ALLOWLIST_REPO_ROOT": _REPO_ROOT}
for key, value in _THROWAWAY_ENV.items():
env.setdefault(key, value)
probe: Final = run_child_interpreter(_COVERAGE_PROBE, env=env, timeout=90)
assert probe.returncode == 0, f"route probe failed:\n{probe.stderr}"
report: Final = json.loads(probe.stdout)
assert not report["lazy_loaded"], (
"route probe was not pristine; it loaded lazy features "
f"{report['lazy_loaded']}, so its route table is not the startup one"
)
backend_paths = _component_paths(
app.router.routes, BACKEND_EXACT_PATHS, BACKEND_PATH_PREFIXES
assert report["route_count"] > 100, (
f"route probe only saw {report['route_count']} routes, so an empty "
"uncovered set would not mean anything"
)
uncovered = all_paths - (gateway_paths | backend_paths)
uncovered: Final = report["uncovered"]
assert not uncovered, (
f"{len(uncovered)} route(s) are not exposed on either component. "
f"Update gateway/routes/allowlist.py or backend/routes/allowlist.py to cover:\n "
+ "\n ".join(sorted(uncovered))
+ "\n ".join(uncovered)
)

View file

@ -3555,6 +3555,23 @@ async def test_load_config_rejects_malformed_role_permissions(tmp_path):
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
@pytest.mark.asyncio
async def test_load_config_compiles_key_alias_pattern_at_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setattr(litellm, "key_alias_pattern", None)
config_file: Final = tmp_path / "config.yaml"
config_file.write_text(yaml.dump({"model_list": [], "litellm_settings": {"key_alias_pattern": "^team-("}}))
with pytest.raises(Exception, match=r"litellm_settings\.key_alias_pattern"):
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert litellm.key_alias_pattern is None
config_file.write_text(yaml.dump({"model_list": [], "litellm_settings": {"key_alias_pattern": "^team-[a-z]+$"}}))
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
assert litellm.key_alias_pattern == "^team-[a-z]+$"
def test_os_environ_resolution_leaves_the_config_layer_holding_the_reference(monkeypatch):
from litellm.proxy.proxy_server import ProxyConfig

View file

@ -41,7 +41,10 @@ ALL_OPUS_5_VARIANTS = (
"jp.anthropic.claude-opus-5",
"vertex_ai/claude-opus-5",
"vertex_ai/claude-opus-5@default",
"vertex_ai/claude-opus-5-5",
"vertex_ai/claude-opus-5-5@default",
"azure_ai/claude-opus-5",
"azure_ai/claude-opus-5-5",
)
BEDROCK_OPUS_5_VARIANTS = (
@ -69,20 +72,37 @@ def test_opus_5_registered_for_bedrock_converse():
assert "anthropic.claude-opus-5" in BEDROCK_CONVERSE_MODELS
def test_opus_5_5_present_in_bundled_backup():
OPUS_5_5_VARIANTS = (
"claude-opus-5-5",
"vertex_ai/claude-opus-5-5",
"vertex_ai/claude-opus-5-5@default",
"azure_ai/claude-opus-5-5",
)
@pytest.mark.parametrize("model_name", OPUS_5_5_VARIANTS)
def test_opus_5_5_present_in_bundled_backup(model_name):
backup = GetModelCostMap.load_local_model_cost_map()
root = _load_root_cost_map()
assert "claude-opus-5-5" in backup
assert "claude-opus-5-5" in root
assert backup["claude-opus-5-5"] == root["claude-opus-5-5"]
assert model_name in backup
assert model_name in root
assert backup[model_name] == root[model_name]
@pytest.mark.parametrize("model", ["claude-opus-5-5", "anthropic/claude-opus-5-5"])
def test_opus_5_5_thinking_profile(local_model_cost_map, model):
@pytest.mark.parametrize(
("model", "provider"),
[
("claude-opus-5-5", "anthropic"),
("anthropic/claude-opus-5-5", "anthropic"),
("vertex_ai/claude-opus-5-5", "vertex_ai"),
("azure_ai/claude-opus-5-5", "azure_ai"),
],
)
def test_opus_5_5_thinking_profile(local_model_cost_map, model, provider):
"""Opus 5.5 has thinking always on with the adaptive thinking surface, and
no forced tool use, same as Fable 5.1."""
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
assert AnthropicModelInfo._is_adaptive_thinking_model(model, "anthropic") is True
assert AnthropicModelInfo._is_always_on_thinking_model(model, "anthropic") is True
assert AnthropicModelInfo._is_adaptive_thinking_model(model, provider) is True
assert AnthropicModelInfo._is_always_on_thinking_model(model, provider) is True
assert AnthropicModelInfo.forced_tool_use_unsupported(model.removeprefix("anthropic/")) is True

View file

@ -9,6 +9,8 @@ from pathlib import Path
import pytest
from tests._process_helpers import process_is_gone
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "pre_commit_lint.sh"
WHOLE_TREE_RUFF = "run --no-sync ruff check --config ruff-tests.toml tests"
@ -343,14 +345,6 @@ def _wait_until(predicate: Callable[[], bool], timeout_seconds: float) -> bool:
return predicate()
def _pid_gone(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return True
return False
def test_interrupt_kills_background_jobs_and_removes_logs(tmp_path: Path) -> None:
repo, bin_dir = _sandbox(tmp_path)
hang_dir = tmp_path / "hang"
@ -372,7 +366,7 @@ def test_interrupt_kills_background_jobs_and_removes_logs(tmp_path: Path) -> Non
os.killpg(proc.pid, signal.SIGINT)
assert proc.wait(timeout=10) != 0
make_pid = int((hang_dir / "make.pid").read_text())
assert _wait_until(lambda: _pid_gone(make_pid), 5)
assert process_is_gone(make_pid, within_seconds=5)
assert _wait_until(lambda: not any(tmp_dir.iterdir()), 5), list(tmp_dir.iterdir())
finally:
with suppress(ProcessLookupError, PermissionError):

View file

@ -0,0 +1,65 @@
"""``process_is_gone`` has to say gone for every shape a killed process can take, and never for a live one."""
import os
import signal
import subprocess
import sys
from pathlib import Path
from typing import Final
import pytest
from tests._process_helpers import process_is_gone
SLEEP_FOREVER: Final = (sys.executable, "-I", "-c", "import time; time.sleep(600)")
LEAVE_A_ZOMBIE_BEHIND: Final = """
import os, signal, subprocess, sys, time
grandchild = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(600)"])
os.kill(grandchild.pid, signal.SIGKILL)
while open(f"/proc/{grandchild.pid}/stat").read().rpartition(")")[2].split()[0] != "Z":
time.sleep(0.01)
print(grandchild.pid, flush=True)
time.sleep(600)
"""
def test_a_live_process_is_not_gone() -> None:
child: Final = subprocess.Popen(SLEEP_FOREVER)
try:
assert not process_is_gone(child.pid, within_seconds=0.3)
finally:
child.kill()
child.wait()
def test_a_reaped_child_is_gone() -> None:
child: Final = subprocess.Popen(SLEEP_FOREVER)
child.kill()
child.wait()
assert process_is_gone(child.pid, within_seconds=1)
@pytest.mark.skipif(os.name == "nt", reason="zombies are a POSIX thing")
def test_an_unreaped_child_is_reaped_and_gone() -> None:
child: Final = subprocess.Popen(SLEEP_FOREVER)
os.kill(child.pid, signal.SIGKILL)
assert process_is_gone(child.pid, within_seconds=1)
with pytest.raises(ChildProcessError):
os.waitpid(child.pid, os.WNOHANG)
@pytest.mark.skipif(not Path("/proc").is_dir(), reason="needs procfs to see a zombie that is not our child")
def test_a_zombie_left_by_another_process_is_gone() -> None:
zombie_factory: Final = subprocess.Popen(
[sys.executable, "-I", "-c", LEAVE_A_ZOMBIE_BEHIND], stdout=subprocess.PIPE, text=True
)
try:
assert zombie_factory.stdout is not None
zombie_pid: Final = int(zombie_factory.stdout.readline())
with pytest.raises(ChildProcessError):
os.waitpid(zombie_pid, os.WNOHANG)
assert process_is_gone(zombie_pid, within_seconds=1)
finally:
zombie_factory.kill()
zombie_factory.wait()

View file

@ -230,6 +230,13 @@ def test_get_model_info_prefers_exact_dated_key_over_stripped(
assert info["key"] == expected_key
def test_get_model_info_internal_failure_is_not_reported_as_unmapped() -> None:
with patch("litellm.utils._get_potential_model_names", side_effect=RuntimeError("malformed metadata")):
with pytest.raises(Exception, match="This model isn't mapped yet") as exc_info:
litellm.utils._get_model_info_helper(model="gpt-4o", custom_llm_provider="openai")
assert not isinstance(exc_info.value, litellm.ModelNotMappedError)
def test_check_provider_match_azure_ai_allows_openai_and_azure():
"""
Test that azure_ai provider can match openai and azure models.

View file

@ -913,3 +913,26 @@ describe("fetchMemoryList search serialization", () => {
expect(lastParams(mockFetch).has("search")).toBe(false);
});
});
describe("userFilterUICall", () => {
let currentFetch: typeof global.fetch;
beforeEach(() => {
currentFetch = global.fetch;
});
afterEach(() => {
global.fetch = currentFetch;
});
it("forwards the search param to /user/filter/ui", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, text: async () => "[]" } as any);
global.fetch = mockFetch as any;
await Networking.userFilterUICall("sk-test", new URLSearchParams({ search: "svc" }));
const parsed = new URL(mockFetch.mock.calls[0][0] as string, "http://localhost");
expect(parsed.pathname).toContain("/user/filter/ui");
expect(parsed.searchParams.get("search")).toBe("svc");
});
});

View file

@ -1983,6 +1983,7 @@ export const userFilterUICall = async (accessToken: string, params: URLSearchPar
user_email: params.get("user_email") || undefined,
user_id: params.get("user_id") || undefined,
team_id: params.get("team_id") || undefined,
search: params.get("search") || undefined,
},
});
} catch (error) {

View file

@ -204,7 +204,8 @@ const openModal = async (props: Partial<React.ComponentProps<typeof CreateKey>>
return view;
};
const userSearchInput = (): Promise<HTMLElement> => screen.findByPlaceholderText("Type email to search for users");
const userSearchInput = (): Promise<HTMLElement> =>
screen.findByPlaceholderText("Type email or user ID to search for users");
const openSection = async (name: RegExp) => {
await userEvent.click(await screen.findByRole("button", { name }));
@ -619,7 +620,7 @@ describe("CreateKey", () => {
it("mounts the user search control only once Another User is chosen", async () => {
await openModal();
expect(screen.queryByPlaceholderText("Type email to search for users")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Type email or user ID to search for users")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
@ -946,18 +947,18 @@ describe("CreateKey", () => {
expect(vi.mocked(userFilterUICall)).toHaveBeenCalledTimes(1);
const params = vi.mocked(userFilterUICall).mock.calls[0][1] as URLSearchParams;
expect(params.get("user_email")).toBe("alice");
expect(params.get("search")).toBe("alice");
} finally {
vi.useRealTimers();
}
});
it("keeps the current search's users when an abandoned search answers last", async () => {
const answers = new Map<string, (users: { user_id: string; user_email: string }[]) => void>();
const answers = new Map<string, (users: { user_id: string; user_email: string | null }[]) => void>();
vi.mocked(userFilterUICall).mockImplementation(
(_accessToken, params) =>
new Promise((resolve) => {
answers.set(params.get("user_email") ?? "", resolve);
answers.set(params.get("search") ?? "", resolve);
}) as never,
);
@ -984,12 +985,36 @@ describe("CreateKey", () => {
expect(screen.getByRole("option", { name: "alice.smith@example.com (u-smith)" })).toBeInTheDocument();
});
it("stops searching once the box is cleared and the abandoned search answers", async () => {
const answers = new Map<string, (users: { user_id: string; user_email: string }[]) => void>();
it("labels a user with no email by their user id", async () => {
const answers = new Map<string, (users: { user_id: string; user_email: string | null }[]) => void>();
vi.mocked(userFilterUICall).mockImplementation(
(_accessToken, params) =>
new Promise((resolve) => {
answers.set(params.get("user_email") ?? "", resolve);
answers.set(params.get("search") ?? "", resolve);
}) as never,
);
const user = userEvent.setup();
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
const search = await userSearchInput();
await user.type(search, "svc");
await waitFor(() => expect(answers.has("svc")).toBe(true), { timeout: 3000 });
await act(async () => {
answers.get("svc")?.([{ user_id: "svc-bot", user_email: null }]);
});
expect(await screen.findByRole("option", { name: "svc-bot" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /null/ })).not.toBeInTheDocument();
});
it("stops searching once the box is cleared and the abandoned search answers", async () => {
const answers = new Map<string, (users: { user_id: string; user_email: string | null }[]) => void>();
vi.mocked(userFilterUICall).mockImplementation(
(_accessToken, params) =>
new Promise((resolve) => {
answers.set(params.get("search") ?? "", resolve);
}) as never,
);
@ -1013,11 +1038,11 @@ describe("CreateKey", () => {
});
it("keeps searching while a newer search is still in flight", async () => {
const answers = new Map<string, (users: { user_id: string; user_email: string }[]) => void>();
const answers = new Map<string, (users: { user_id: string; user_email: string | null }[]) => void>();
vi.mocked(userFilterUICall).mockImplementation(
(_accessToken, params) =>
new Promise((resolve) => {
answers.set(params.get("user_email") ?? "", resolve);
answers.set(params.get("search") ?? "", resolve);
}) as never,
);
@ -1047,12 +1072,12 @@ describe("CreateKey", () => {
it("only warns about a failed search when it is the one the box is waiting on", async () => {
const answers = new Map<
string,
{ resolve: (users: { user_id: string; user_email: string }[]) => void; reject: (error: Error) => void }
{ resolve: (users: { user_id: string; user_email: string | null }[]) => void; reject: (error: Error) => void }
>();
vi.mocked(userFilterUICall).mockImplementation(
(_accessToken, params) =>
new Promise((resolve, reject) => {
answers.set(params.get("user_email") ?? "", { resolve, reject });
answers.set(params.get("search") ?? "", { resolve, reject });
}) as never,
);
@ -1099,9 +1124,7 @@ describe("CreateKey", () => {
];
vi.mocked(userFilterUICall).mockImplementation(
(_accessToken, params) =>
Promise.resolve(
directory.filter((entry) => entry.user_email.includes(params.get("user_email") ?? "")),
) as never,
Promise.resolve(directory.filter((entry) => entry.user_email.includes(params.get("search") ?? ""))) as never,
);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

View file

@ -161,7 +161,7 @@ interface CreateKeyProps {
interface User {
user_id: string;
user_email: string;
user_email: string | null;
role?: string;
}
@ -570,7 +570,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
setUserSearchLoading(true);
try {
const params = new URLSearchParams();
params.append("user_email", searchText); // Always search by email
params.append("search", searchText);
if (accessToken == null) {
return;
}
@ -579,7 +579,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const data: User[] = response;
const options: SearchSelectOption[] = data.map((user) => ({
label: `${user.user_email} (${user.user_id})`,
label: user.user_email ? `${user.user_email} (${user.user_id})` : user.user_id,
value: user.user_id,
}));
@ -729,7 +729,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
onValueChange={control.onChange}
onSearchChange={fetchUsers}
isLoading={userSearchLoading}
placeholder="Type email to search for users"
placeholder="Type email or user ID to search for users"
emptyText="No users found"
loadingText="Searching..."
inputId={control.id}
@ -741,7 +741,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
Create User
</Button>
</div>
<div className="text-xs text-muted-foreground">Search by email to find users</div>
<div className="text-xs text-muted-foreground">Search by email or user ID to find users</div>
</div>
)}
</MountedFormField>

View file

@ -17325,7 +17325,7 @@ export interface paths {
};
/**
* Ui View Users
* @description Filter users based on partial match of user_id or email with pagination.
* @description Filter users based on partial match of user_id or email, or combined ``search``, with pagination.
*
* Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag
* (stored in the ``litellm_uisettings`` table):
@ -64338,6 +64338,8 @@ export interface operations {
user_id?: string | null;
/** @description User email in the request parameters */
user_email?: string | null;
/** @description Combined search: matches users whose 'user_id' or 'user_email' contains the value (case-insensitive). */
search?: string | null;
/** @description Team ID — used when a team admin searches for users to add to their team */
team_id?: string | null;
/** @description Page number for pagination */