Merge origin/litellm_internal_staging into litellm_rust_vertex_auth
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-07-20 17:35:35 +00:00
commit fa46ad7767
79 changed files with 1786 additions and 446 deletions

View file

@ -7,7 +7,8 @@ members = [
resolver = "2"
[workspace.package]
edition = "2021"
edition = "2024"
rust-version = "1.88"
license = "MIT"
repository = "https://github.com/BerriAI/litellm"

View file

@ -9,9 +9,9 @@
//! runs during extraction, before the handler body. Routes never re-implement it.
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use axum::http::StatusCode;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;

View file

@ -1 +1 @@
pub use crate::messages::{messages, MessagesRequest};
pub use crate::messages::{MessagesRequest, messages};

View file

@ -1 +1 @@
pub use crate::ocr::{ocr, OcrRequest};
pub use crate::ocr::{OcrRequest, ocr};

View file

@ -15,16 +15,16 @@ use std::time::Duration;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{Sink, SinkExt, Stream, StreamExt};
use litellm_core::CoreResult;
use litellm_core::error::CoreError;
use litellm_core::realtime::transformation::RealtimeProviderConfig;
use litellm_core::realtime::types::RealtimeEvent;
use litellm_core::CoreResult;
use tokio::net::TcpStream;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
use litellm_core::providers::openai::realtime::transformation::OPENAI_REALTIME_CONFIG;
@ -113,7 +113,7 @@ pub(crate) async fn read_event(upstream_rx: &mut UpstreamRx) -> CoreResult<Realt
Message::Close(_) => {
return Err(CoreError::Network(
"upstream closed before first event".to_string(),
))
));
}
_ => continue,
}

View file

@ -28,11 +28,11 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use litellm_core::realtime::types::RealtimeEvent;
use litellm_core::CoreResult;
use litellm_core::realtime::types::RealtimeEvent;
use crate::io::realtime::{
dial_upstream, read_event, resolve_api_key, UpstreamRx, UpstreamTx, UpstreamWs,
UpstreamRx, UpstreamTx, UpstreamWs, dial_upstream, read_event, resolve_api_key,
};
/// Default target warm sockets per key when pooling is enabled.
@ -473,8 +473,8 @@ pub fn upstream_key(
/// unhealthy — we'd rather discard and fresh-dial than hand over a socket in an
/// unexpected state. `Pending` (the healthy case) returns `false`.
fn is_dead(rx: &mut UpstreamRx) -> bool {
use futures_util::task::noop_waker_ref;
use futures_util::Stream;
use futures_util::task::noop_waker_ref;
use std::pin::Pin;
use std::task::{Context, Poll};
@ -523,15 +523,15 @@ mod tests {
))
.await;
while let Some(Ok(msg)) = ws.next().await {
if let Message::Text(text) = msg {
if text.contains("response.create") {
for frame in [
r#"{"type":"response.created"}"#,
r#"{"type":"response.output_audio.delta","delta":"AAAA"}"#,
r#"{"type":"response.done"}"#,
] {
let _ = ws.send(Message::Text(frame.to_string())).await;
}
if let Message::Text(text) = msg
&& text.contains("response.create")
{
for frame in [
r#"{"type":"response.created"}"#,
r#"{"type":"response.output_audio.delta","delta":"AAAA"}"#,
r#"{"type":"response.done"}"#,
] {
let _ = ws.send(Message::Text(frame.to_string())).await;
}
}
}

View file

@ -10,19 +10,18 @@ use litellm_core::responses::websocket::ResponsesWebSocketProviderConfig;
use litellm_core::{CoreError, CoreResult};
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header::{HeaderName, AUTHORIZATION};
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
use crate::constants::{
DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS, DEFAULT_RESPONSES_WS_IDLE_TIMEOUT_SECS,
};
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
const MISSING_KEY_MESSAGE: &str =
"Missing OpenAI API Key - a Responses WebSocket call is being made but no key was passed via params or the OPENAI_API_KEY environment variable";
const MISSING_KEY_MESSAGE: &str = "Missing OpenAI API Key - a Responses WebSocket call is being made but no key was passed via params or the OPENAI_API_KEY environment variable";
pub type ResponsesUpstreamWs = WebSocketStream<MaybeTlsStream<TcpStream>>;
type UpstreamTx = SplitSink<ResponsesUpstreamWs, Message>;
@ -83,8 +82,8 @@ impl ResponsesWebSocketConnection {
}
pub async fn recv_text(&self) -> CoreResult<Option<String>> {
let mut socket = self.socket.lock().await;
let Some(socket) = socket.as_mut() else {
let mut socket_guard = self.socket.lock().await;
let Some(socket) = socket_guard.as_mut() else {
return Ok(None);
};
match socket.next().await {
@ -456,9 +455,11 @@ mod tests {
assert_eq!(fourth.event_type, ResponsesWsEventType::ResponseCompleted);
let observed: Vec<_> = observed_rx.collect().await;
assert_eq!(observed.len(), 4);
assert!(observed
.iter()
.all(|event| event.event_type != ResponsesWsEventType::ResponseCreate));
assert!(
observed
.iter()
.all(|event| event.event_type != ResponsesWsEventType::ResponseCreate)
);
}
#[tokio::test]

View file

@ -11,7 +11,7 @@
use std::sync::Arc;
use litellm_ai_gateway::io::realtime_pool::{upstream_key, PoolConfig, RealtimePool};
use litellm_ai_gateway::io::realtime_pool::{PoolConfig, RealtimePool, upstream_key};
use litellm_ai_gateway::routes;
use litellm_ai_gateway::state::AppState;
use litellm_core::router::{Deployment, LiteLLMParams, Router};

View file

@ -1,8 +1,8 @@
use litellm_core::error::{json_type_name, CoreError};
use litellm_core::CoreResult;
use litellm_core::error::{CoreError, json_type_name};
use litellm_core::messages::transformation::AnthropicMessagesProviderConfig;
use litellm_core::providers::anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG;
use litellm_core::providers::azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG;
use litellm_core::CoreResult;
use serde_json::{Map, Value};
use crate::constants::MESSAGES_ERROR_BODY_MAX_CHARS;

View file

@ -1,5 +1,5 @@
use litellm_core::error::CoreError;
use litellm_core::CoreResult;
use litellm_core::error::CoreError;
use serde_json::Value;
use super::client::http_client;

View file

@ -1,7 +1,7 @@
use litellm_core::messages::transformation::MessagesAuthStrategy;
use litellm_core::routing_utils::provider::{get_custom_llm_provider, CustomLlmProvider};
use litellm_core::CoreError;
use litellm_core::CoreResult;
use litellm_core::messages::transformation::MessagesAuthStrategy;
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
use super::common_utils::{has_header, messages_provider_config, string_headers};
use super::types::{MessagesRequest, ProviderMessagesRequest};

View file

@ -1,14 +1,14 @@
use std::time::Duration;
use litellm_core::error::CoreError;
use serde_json::{json, Map, Value};
use serde_json::{Map, Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::common_utils::{
has_header, messages_provider_config, string_headers, truncate_error_body,
};
use super::{messages, MessagesRequest};
use super::{MessagesRequest, messages};
async fn read_http_request(socket: &mut TcpStream) -> String {
let mut request = Vec::new();

View file

@ -1,11 +1,11 @@
use std::net::IpAddr;
use std::time::{Duration, Instant};
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use litellm_core::CoreResult;
use litellm_core::error::CoreError;
use litellm_core::ocr::transformation::OcrProviderConfig;
use litellm_core::CoreResult;
use reqwest::Url;
use serde_json::{Map, Value};

View file

@ -1,6 +1,6 @@
use litellm_core::CoreResult;
use litellm_core::error::CoreError;
use litellm_core::ocr::transformation::OcrResponseHandling;
use litellm_core::CoreResult;
use serde_json::Value;
use super::client::http_client;

View file

@ -1,11 +1,11 @@
use std::future::Future;
use std::pin::Pin;
use litellm_core::CoreResult;
use litellm_core::call_lifecycle::{CallLifecycleContext, CallLifecycleHooks, CallLifecycleTiming};
use litellm_core::error::CoreError;
use litellm_core::ocr::transformation::OcrAuthStrategy;
use litellm_core::CoreResult;
use serde_json::{json, Map, Value};
use serde_json::{Map, Value, json};
use super::common_utils::{
convert_document_url_to_data_uri, has_header, ocr_provider_config, string_headers,
@ -292,7 +292,7 @@ fn parse_ocr_pre_call_guardrail_request(
Some(_) => {
return Err(CoreError::InvalidRequest(
"OCR pre_call guardrail optional_params must be an object".to_string(),
))
));
}
None => Map::new(),
};

View file

@ -1,5 +1,5 @@
use litellm_core::call_lifecycle::CallLifecycle;
use litellm_core::CoreResult;
use litellm_core::call_lifecycle::CallLifecycle;
use serde_json::Value;
mod client;
@ -12,7 +12,7 @@ mod types;
pub use types::OcrRequest;
use handler::execute_ocr_provider_call;
use prepare::{prepare_ocr_call, PreparedOcrCall};
use prepare::{PreparedOcrCall, prepare_ocr_call};
pub async fn ocr(request: OcrRequest<'_>) -> CoreResult<Value> {
let PreparedOcrCall { request, hooks } = prepare_ocr_call(request);

View file

@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use litellm_core::routing_utils::provider::{get_custom_llm_provider, CustomLlmProvider};
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
use super::hooks::OcrLifecycleHooks;
use super::types::{OcrRequest, PreparedOcrRequest};

View file

@ -3,12 +3,12 @@ use std::time::Duration;
use litellm_core::error::CoreError;
use litellm_core::ocr::transformation::OcrResponseHandling;
use serde_json::{json, Map, Value};
use serde_json::{Map, Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::common_utils::{has_header, ocr_provider_config, string_headers, truncate_error_body};
use super::{ocr, OcrRequest};
use super::{OcrRequest, ocr};
use crate::integrations::custom_guardrail::{
CustomGuardrail, GuardrailContext, GuardrailDecision, GuardrailError, GuardrailEventHook,
GuardrailFuture, GuardrailRequest,
@ -228,19 +228,23 @@ fn truncate_error_body_does_not_split_multibyte_chars() {
#[test]
fn ocr_dispatch_supports_migrated_providers() {
assert!(ocr_provider_config("mistral", "mistral-ocr-latest").is_some());
assert!(ocr_provider_config("azure_ai", "pixtral-12b-2409")
.expect("azure ai config resolves")
.requires_data_uri_document());
assert!(
ocr_provider_config("azure_ai", "pixtral-12b-2409")
.expect("azure ai config resolves")
.requires_data_uri_document()
);
assert_eq!(
ocr_provider_config("azure_ai", "doc-intelligence/prebuilt-read")
.expect("document intelligence config resolves")
.response_handling(),
OcrResponseHandling::AzureDocumentIntelligencePoll
);
assert!(ocr_provider_config("vertex_ai", "deepseek-ocr-maas")
.expect("vertex deepseek config resolves")
.supported_ocr_params()
.contains(&"temperature"));
assert!(
ocr_provider_config("vertex_ai", "deepseek-ocr-maas")
.expect("vertex deepseek config resolves")
.supported_ocr_params()
.contains(&"temperature")
);
assert!(ocr_provider_config("openai", "gpt-4o").is_none());
}

View file

@ -7,9 +7,9 @@
//!
//! Compiled only under the `python-config` feature.
use litellm_core::CoreResult;
use litellm_core::error::CoreError;
use litellm_core::router::{Deployment, Router};
use litellm_core::CoreResult;
use pyo3::prelude::*;
use crate::gil;

View file

@ -1,8 +1,8 @@
//! Health probes. Simple-route template: a `router()` plus its handlers, in one file.
use axum::Router;
use axum::http::StatusCode;
use axum::routing::get;
use axum::Router;
use crate::state::AppState;

View file

@ -2,13 +2,13 @@
mod service;
use axum::Router;
use axum::body::Body;
use axum::extract::{Json, State};
use axum::http::header::{HeaderMap, HeaderValue, CACHE_CONTROL, CONTENT_TYPE};
use axum::http::StatusCode;
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, HeaderMap, HeaderValue};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::Router;
use litellm_core::CoreError;
use serde_json::{Map, Value};
@ -125,9 +125,9 @@ mod tests {
use std::sync::Arc;
use axum::body::Body;
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use axum::http::Request;
use axum::http::StatusCode;
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use litellm_core::router::{Deployment, LiteLLMParams, Router as ModelRouter};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@ -439,8 +439,8 @@ mod tests {
.await
.expect("response body reads");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&response_body).expect("error is json")
["error"]["message"],
serde_json::from_slice::<serde_json::Value>(&response_body).expect("error is json")["error"]
["message"],
"messages provider request failed"
);
server.await.expect("upstream task completes");

View file

@ -5,7 +5,7 @@ use litellm_core::{CoreError, CoreResult};
use serde_json::{Map, Value};
use crate::constants::ANTHROPIC_MESSAGES_PROVIDER;
use crate::messages::{execute_messages, MessagesRequest};
use crate::messages::{MessagesRequest, execute_messages};
pub(crate) enum MessagesResponse {
Json(Value),

View file

@ -6,17 +6,17 @@
mod service;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::io::realtime_pool::RealtimePool;
use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use futures_util::{SinkExt, StreamExt};
use litellm_core::realtime::types::RealtimeEvent;
use litellm_core::router::Router as ModelRouter;

View file

@ -9,12 +9,12 @@
use std::time::Duration;
use crate::io::realtime_pool::{upstream_key, RealtimePool};
use crate::io::realtime_pool::{RealtimePool, upstream_key};
use futures_util::{Sink, Stream};
use litellm_core::CoreResult;
use litellm_core::error::CoreError;
use litellm_core::realtime::types::RealtimeEvent;
use litellm_core::router::Router;
use litellm_core::CoreResult;
/// Select a deployment for `model` and splice the client stream to the provider.
///

View file

@ -1,15 +1,15 @@
mod service;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use futures_util::{Sink, SinkExt, StreamExt};
use litellm_core::responses::types::{ResponsesErrorFrame, ResponsesWsEvent, ResponsesWsEventType};
use litellm_core::router::Router as ModelRouter;

View file

@ -134,8 +134,8 @@ impl<V: Clone> InMemoryCache<V> {
#[cfg(test)]
mod tests {
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
atomic::{AtomicU64, Ordering},
};
use super::InMemoryCache;

View file

@ -5,7 +5,7 @@ use crate::messages::types::{
MessageContent, SystemPrompt,
};
use crate::providers::anthropic::messages::transformation::{
non_empty, AnthropicMessagesConfig, ANTHROPIC_MESSAGES_CONFIG,
ANTHROPIC_MESSAGES_CONFIG, AnthropicMessagesConfig, non_empty,
};
use serde_json::{Map, Value};

View file

@ -1,9 +1,9 @@
use std::collections::BTreeSet;
use crate::error::{json_type_name, CoreError, CoreResult};
use crate::error::{CoreError, CoreResult, json_type_name};
use crate::ocr::transformation::{OcrAuthStrategy, OcrProviderConfig, OcrResponseHandling};
use crate::ocr::types::{OcrRequestData, OcrResponseData};
use serde_json::{json, Map, Value};
use serde_json::{Map, Value, json};
use crate::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG;
@ -206,11 +206,11 @@ pub fn complete_document_intelligence_url(
AZURE_DOCUMENT_INTELLIGENCE_API_VERSION
);
if let Some(pages) = optional_params.get("pages") {
if let Some(normalized) = normalize_pages_param(pages)? {
url.push_str("&pages=");
url.push_str(&normalized);
}
if let Some(pages) = optional_params.get("pages")
&& let Some(normalized) = normalize_pages_param(pages)?
{
url.push_str("&pages=");
url.push_str(&normalized);
}
Ok(url)
@ -231,7 +231,7 @@ fn document_url_from_mistral_document(document: &Value) -> CoreResult<&str> {
other => {
return Err(CoreError::InvalidRequest(format!(
"Invalid document type: {other}. Must be 'document_url' or 'image_url'"
)))
)));
}
};
object

View file

@ -5,10 +5,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
use crate::caching::in_memory_cache::InMemoryCache;
use crate::error::{CoreError, CoreResult};
use aws_credential_types::provider::ProvideCredentials;
use aws_credential_types::Credentials;
use aws_credential_types::provider::ProvideCredentials;
use aws_sigv4::http_request::{
sign, SignableBody, SignableRequest, SigningParams, SigningSettings,
SignableBody, SignableRequest, SigningParams, SigningSettings, sign,
};
use aws_sigv4::sign::v4;
use aws_smithy_runtime_api::client::identity::Identity;
@ -368,11 +368,11 @@ async fn is_already_running_as_role(role: &str, config: &AwsAuthConfig) -> CoreR
if let (Ok(current_role), Ok(token_file)) = (
std::env::var(AWS_ROLE_ARN),
std::env::var(AWS_WEB_IDENTITY_TOKEN_FILE),
) {
if !token_file.is_empty() {
return Ok(same_role_arns(role, &current_role));
}
) && !token_file.is_empty()
{
return Ok(same_role_arns(role, &current_role));
}
let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(region) = config.region_name.clone() {
loader = loader.region(aws_types::region::Region::new(region));
@ -639,7 +639,9 @@ mod tests {
);
assert_eq!(
signed.get("Authorization").map(String::as_str),
Some("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240102/us-east-1/bedrock/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token, Signature=55c027ef47527d3ad63f1735f9d099efdbc99f296ff914bd94e727e24ec0e464")
Some(
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20240102/us-east-1/bedrock/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token, Signature=55c027ef47527d3ad63f1735f9d099efdbc99f296ff914bd94e727e24ec0e464"
)
);
}

View file

@ -1,4 +1,4 @@
use crate::error::{json_type_name, CoreError, CoreResult};
use crate::error::{CoreError, CoreResult, json_type_name};
use crate::ocr::transformation::OcrProviderConfig;
use crate::ocr::types::{OcrRequestData, OcrResponseData};
use serde_json::{Map, Value};

View file

@ -1,6 +1,6 @@
use crate::CoreResult;
use crate::realtime::transformation::RealtimeProviderConfig;
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
use crate::CoreResult;
/// Default OpenAI API base, used when the caller does not override `api_base`.
pub const OPENAI_REALTIME_DEFAULT_API_BASE: &str = "https://api.openai.com";

View file

@ -1,6 +1,6 @@
use crate::responses::types::{ResponsesWsEvent, ResponsesWsTransformResult};
use crate::responses::websocket::{enforce_model, ResponsesWebSocketProviderConfig};
use crate::CoreResult;
use crate::responses::types::{ResponsesWsEvent, ResponsesWsTransformResult};
use crate::responses::websocket::{ResponsesWebSocketProviderConfig, enforce_model};
pub struct OpenAIResponsesWsConfig;

View file

@ -1,7 +1,7 @@
use crate::error::{json_type_name, CoreError, CoreResult};
use crate::error::{CoreError, CoreResult, json_type_name};
use crate::ocr::transformation::OcrProviderConfig;
use crate::ocr::types::{OcrRequestData, OcrResponseData};
use serde_json::{json, Map, Value};
use serde_json::{Map, Value, json};
use crate::providers::mistral::ocr::transformation::MISTRAL_OCR_CONFIG;
@ -140,7 +140,7 @@ fn document_content_item(document: &Value) -> CoreResult<Value> {
other => {
return Err(CoreError::InvalidRequest(format!(
"Unsupported document type: {other}. Expected 'image_url' or 'document_url'"
)))
)));
}
};
let url = object

View file

@ -7,10 +7,10 @@ use std::fs;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use google_cloud_auth::credentials::AccessTokenCredentials;
use google_cloud_auth::credentials::external_account;
use google_cloud_auth::credentials::service_account;
use google_cloud_auth::credentials::user_account;
use google_cloud_auth::credentials::AccessTokenCredentials;
use serde_json::Value;
use sha2::{Digest, Sha256};

View file

@ -1,5 +1,5 @@
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
use crate::CoreResult;
use crate::realtime::types::{RealtimeEvent, RealtimeTransformResult};
pub trait RealtimeProviderConfig {
/// Build the upstream WebSocket URL (e.g. `wss://api.openai.com/v1/realtime?model=…`).

View file

@ -1,6 +1,6 @@
use crate::CoreResult;
use crate::constants::{OPENAI_RESPONSES_DEFAULT_API_BASE, OPENAI_RESPONSES_PATH};
use crate::responses::types::{ResponsesWsEvent, ResponsesWsEventType, ResponsesWsTransformResult};
use crate::CoreResult;
pub trait ResponsesWebSocketProviderConfig: Sync {
fn supports_native_websocket(&self) -> bool {

View file

@ -1,8 +1,8 @@
use std::collections::HashMap;
use std::time::Duration;
use litellm_ai_gateway::io::messages::{messages as run_messages, MessagesRequest};
use litellm_ai_gateway::io::ocr::{ocr as run_ocr, OcrRequest};
use litellm_ai_gateway::io::messages::{MessagesRequest, messages as run_messages};
use litellm_ai_gateway::io::ocr::{OcrRequest, ocr as run_ocr};
use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection as RustResponsesWebSocketConnection;
use litellm_core::error::CoreError;
use pyo3::exceptions::{PyRuntimeError, PyValueError};

View file

@ -1292,6 +1292,7 @@ MAXIMUM_TRACEBACK_LINES_TO_LOG = int(os.getenv("MAXIMUM_TRACEBACK_LINES_TO_LOG",
X_LITELLM_DISABLE_CALLBACKS = "x-litellm-disable-callbacks"
LITELLM_METADATA_FIELD = "litellm_metadata"
OLD_LITELLM_METADATA_FIELD = "metadata"
RETURN_RAW_MODEL_NAME_METADATA_KEY = "_complexity_router_return_raw_model_name"
LITELLM_TRUNCATED_PAYLOAD_FIELD = "litellm_truncated"
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE = (
"Truncation is a DB storage safeguard. "

View file

@ -1,8 +1,8 @@
import base64
import json # <--- NEW
import json
import os
from datetime import datetime
from typing import TYPE_CHECKING, Any, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from litellm._logging import verbose_logger
from litellm.integrations.arize import _utils
@ -25,6 +25,8 @@ else:
LANGFUSE_CLOUD_EU_ENDPOINT = "https://cloud.langfuse.com/api/public/otel"
LANGFUSE_CLOUD_US_ENDPOINT = "https://us.cloud.langfuse.com/api/public/otel"
LANGFUSE_INGESTION_VERSION_HEADER = "x-langfuse-ingestion-version"
LANGFUSE_INGESTION_VERSION = "4"
class LangfuseOtelLogger(OpenTelemetry):
@ -326,7 +328,9 @@ class LangfuseOtelLogger(OpenTelemetry):
return OpenTelemetryConfig(
exporter="otlp_http",
endpoint=endpoint,
headers=f"Authorization={auth_header}",
headers=LangfuseOtelLogger._format_otel_headers(
LangfuseOtelLogger._build_langfuse_otel_headers(auth_header)
),
)
@staticmethod
@ -338,6 +342,26 @@ class LangfuseOtelLogger(OpenTelemetry):
auth_header = base64.b64encode(auth_string.encode()).decode()
return f"Basic {auth_header}"
@staticmethod
def _build_langfuse_otel_headers(auth_header: str) -> Dict[str, str]:
"""
Build the OTLP header set Langfuse expects.
`x-langfuse-ingestion-version: 4` selects Langfuse's v4 ingestion path;
without it spans fall back to the older transformation path.
"""
return {
"Authorization": auth_header,
LANGFUSE_INGESTION_VERSION_HEADER: LANGFUSE_INGESTION_VERSION,
}
@staticmethod
def _format_otel_headers(headers: Dict[str, str]) -> str:
"""
Serialize a header mapping into the comma-separated OTLP header string
"""
return ",".join(f"{key}={value}" for key, value in headers.items())
def construct_dynamic_otel_headers(
self, standard_callback_dynamic_params: StandardCallbackDynamicParams
) -> Optional[dict]:
@ -358,7 +382,7 @@ class LangfuseOtelLogger(OpenTelemetry):
public_key=dynamic_langfuse_public_key,
secret_key=dynamic_langfuse_secret_key,
)
dynamic_headers["Authorization"] = auth_header
dynamic_headers.update(LangfuseOtelLogger._build_langfuse_otel_headers(auth_header))
return dynamic_headers

View file

@ -133,6 +133,32 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig):
def get_config(cls):
return super().get_config()
def validate_environment(
self,
headers: dict,
model: str,
messages: List[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: str | None = None,
api_base: str | None = None,
) -> dict:
api_key = self._get_api_key(api_key)
if api_key is None:
raise ValueError("FIREWORKS_API_KEY is not set")
validated_headers = OpenAIGPTConfig.validate_environment(
self,
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=api_key,
api_base=api_base,
)
return self._add_session_affinity_header(validated_headers, litellm_params)
def get_supported_openai_params(self, model: str):
# Base parameters supported by all models
supported_params = [

View file

@ -64,9 +64,16 @@ class FireworksAIMixin:
if api_key is None:
raise ValueError("FIREWORKS_API_KEY is not set")
validated_headers = {"Authorization": "Bearer {}".format(api_key), **headers}
if not any(key.lower() == "x-session-affinity" for key in validated_headers):
session_id = get_fireworks_session_id(litellm_params)
if session_id:
validated_headers["x-session-affinity"] = session_id
return validated_headers
auth_headers = {"Authorization": "Bearer {}".format(api_key), **headers}
content_type_header = (
{} if any(key.lower() == "content-type" for key in auth_headers) else {"Content-Type": "application/json"}
)
return self._add_session_affinity_header({**auth_headers, **content_type_header}, litellm_params)
def _add_session_affinity_header(self, headers: dict, litellm_params: dict) -> dict:
if any(key.lower() == "x-session-affinity" for key in headers):
return headers
session_id = get_fireworks_session_id(litellm_params)
if not session_id:
return headers
return {**headers, "x-session-affinity": session_id}

View file

@ -10,6 +10,10 @@ from typing_extensions import assert_never
import litellm
from litellm._logging import verbose_logger
from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_request_base_url,
well_known_root_suffix,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
BridgeEnvelopeInvalid,
@ -120,6 +124,96 @@ def _has_client_supplied_mcp_auth(
return bool(mcp_auth_header) or bool(mcp_server_auth_headers)
def _is_aggregate_gateway_dcr_challenge_scope(
route: str,
mcp_servers: list[str] | None,
mcp_auth_header: str | None,
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
exc: Exception,
) -> bool:
"""True when an unauthenticated request to the aggregate ``/mcp`` endpoint
should receive the RFC 9728 401 challenge that advertises the gateway as
the authorization server.
Fires only for a genuine 401 on the aggregate scope: any named target
(path or ``x-mcp-servers``) belongs to the per-server challenge paths, and
client-supplied MCP auth headers mean the caller is not a cold-start DCR
client. Fails closed to the original admission error otherwise."""
if not _is_litellm_auth_admission_error(exc):
return False
if mcp_servers:
return False
if _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers):
return False
return len(MCPRequestHandler._extract_target_server_names_from_path(route)) == 0
def _aggregate_gateway_dcr_challenge(request: Request, invalid_token: bool) -> HTTPException:
"""The RFC 9728 challenge for the aggregate endpoint: points the client at
the gateway's own protected-resource metadata so a DCR client discovers
the gateway as its authorization server and starts the sign-in flow.
``invalid_token`` adds the RFC 6750 error code for a request that DID
present a bearer that failed admission (expired or revoked), telling
spec-compliant clients to re-authorize rather than retry; a request with
no credentials at all gets the bare challenge per RFC 6750 section 3.1."""
error_attr = 'error="invalid_token", ' if invalid_token else ""
resource_metadata_url = (
f"{get_request_base_url(request)}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp"
)
return HTTPException(
status_code=401,
detail={
"error": "authentication_required",
"message": "Authenticate with the gateway to use the MCP endpoint.",
},
headers={"WWW-Authenticate": f'Bearer {error_attr}resource_metadata="{resource_metadata_url}"'},
)
def _admission_failure_fallback(
request: Request,
request_route: str,
mcp_servers: list[str] | None,
mcp_auth_header: str | None,
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
exc: Exception,
bearer_presented: bool,
) -> UserAPIKeyAuth:
"""Map a failed LiteLLM admission to its anonymous fallback or challenge.
Two fallbacks exist, both gated on a genuine 401 with no client-supplied
MCP auth headers. The pass-through cold start (RFC 9728 / MCP
Authorization spec discovery return) admits anonymously so the route's
401 emitter can produce the per-server challenge. The aggregate
gateway-DCR scope converts the failure into the gateway's own
resource_metadata challenge, with the RFC 6750 ``invalid_token`` error
code when the caller DID present a bearer (an expired gateway session
must re-authorize, not retry a dead token). Anything else re-raises the
original admission error unchanged."""
mcp_servers_from_path = _parse_mcp_server_names_from_path(request_route, mcp_servers)
if (
mcp_servers_from_path is not None
and not _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers)
and _is_litellm_auth_admission_error(exc)
and _is_mcp_passthrough_cold_start(
mcp_servers_from_path,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
)
):
verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter")
return UserAPIKeyAuth()
if _is_aggregate_gateway_dcr_challenge_scope(
route=request_route,
mcp_servers=mcp_servers,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
exc=exc,
):
raise _aggregate_gateway_dcr_challenge(request, invalid_token=bearer_presented) from exc
raise exc
class MCPRequestHandler:
"""
Class to handle MCP request processing, including:
@ -271,56 +365,32 @@ class MCPRequestHandler:
elif oauth2_headers:
# Authorization on a non-delegated server: the bearer must be a real
# LiteLLM credential, so a failed validation is a genuine 401/403 and
# propagates. The sole anonymous fallback is the auth_type=none
# pass-through cold-start (RFC 9728 discovery return), gated on a 401
# so a recognized-but-forbidden key still fails closed.
client_ip = IPAddressUtils.get_mcp_client_ip(request)
# propagates unless a fallback in _admission_failure_fallback applies.
try:
validated_user_api_key_auth = await user_api_key_auth(api_key=litellm_api_key, request=request)
except (HTTPException, ProxyException) as e:
# ProxyException.code is normalized to str (possibly "None"), so
# compare both int and str forms rather than coercing.
status = e.status_code if isinstance(e, HTTPException) else e.code
is_unauthenticated = status in (401, "401")
mcp_servers_from_path = _parse_mcp_server_names_from_path(request_route, mcp_servers)
if (
is_unauthenticated
and mcp_servers_from_path is not None
and not _has_client_supplied_mcp_auth(
mcp_auth_header,
mcp_server_auth_headers,
)
and _is_mcp_passthrough_cold_start(mcp_servers_from_path, client_ip=client_ip)
):
verbose_logger.debug(
"MCP pass-through return: forwarding Authorization as upstream OAuth token for delegated auth"
)
validated_user_api_key_auth = UserAPIKeyAuth()
else:
raise
validated_user_api_key_auth = _admission_failure_fallback(
request=request,
request_route=request_route,
mcp_servers=mcp_servers,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
exc=e,
bearer_presented=True,
)
else:
try:
validated_user_api_key_auth = await user_api_key_auth(api_key=litellm_api_key, request=request)
except (HTTPException, ProxyException) as exc:
# Cold-start MCP OAuth discovery: RFC 9728 / MCP Authorization spec
# require unauthenticated requests to protected resources to receive
# 401 + WWW-Authenticate. Defer to _raise_preemptive_401_for_unauthenticated_servers
# for pass-through servers instead of surfacing a generic admission error.
mcp_servers_from_path = _parse_mcp_server_names_from_path(request_route, mcp_servers)
client_ip = IPAddressUtils.get_mcp_client_ip(request)
if (
mcp_servers_from_path is not None
and not _has_client_supplied_mcp_auth(
mcp_auth_header,
mcp_server_auth_headers,
)
and _is_litellm_auth_admission_error(exc)
and _is_mcp_passthrough_cold_start(mcp_servers_from_path, client_ip=client_ip)
):
verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter")
validated_user_api_key_auth = UserAPIKeyAuth()
else:
raise
validated_user_api_key_auth = _admission_failure_fallback(
request=request,
request_route=request_route,
mcp_servers=mcp_servers,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
exc=exc,
bearer_presented=False,
)
return (
validated_user_api_key_auth,

View file

@ -43,6 +43,7 @@ from litellm.proxy._experimental.mcp_server.oauth_utils import (
TOKEN_NO_CACHE_HEADERS,
get_request_base_url,
validate_trusted_redirect_uri,
well_known_root_suffix,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
@ -50,7 +51,6 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
encrypt_value_helper,
)
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.proxy.utils import get_server_root_path
from litellm.types.mcp import MCPAuth, MCPCredentials
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -1838,11 +1838,88 @@ def _jwt_auth_issuers() -> list:
return issuers
def _build_aggregate_protected_resource_response(request: Request) -> dict:
"""RFC 9728 metadata for the aggregate /mcp resource: the gateway itself is
the authorization server. No per-server names or scopes leak here; access
is resolved after sign-in from the authenticated user's grants.
The advertised authorization server is ``{base}/mcp`` (not the bare
origin) so RFC 8414 path-insertion resolves its metadata at
``/.well-known/oauth-authorization-server/mcp``, a route this module
owns. The bare-origin well-known is registered first by the BYOK OAuth
feature and describes the BYOK flow, so it must not be the aggregate
discovery entry point (same pattern as the per-server documents, which
advertise ``{base}/{server_name}``)."""
request_base_url = get_request_base_url(request)
return {
"authorization_servers": [f"{request_base_url}/mcp"],
"resource": f"{request_base_url}/mcp",
"scopes_supported": [],
}
def _build_aggregate_authorization_server_response(request: Request) -> dict:
"""RFC 8414 metadata for the gateway as the aggregate authorization server.
The issuer is ``{base}/mcp`` and must stay equal to the value the
aggregate protected-resource document advertises: spec clients verify the
issuer in the metadata matches the one that derived the well-known URL.
Advertises the root /authorize, /token, and /register endpoints and
``token_endpoint_auth_methods_supported: ["none", ...]`` because DCR
clients (Claude Desktop, MCP Inspector) register as public clients; PKCE
S256 is mandatory in the gateway's authorize flow."""
request_base_url = get_request_base_url(request)
return {
"issuer": f"{request_base_url}/mcp",
"authorization_endpoint": f"{request_base_url}/authorize",
"token_endpoint": f"{request_base_url}/token",
"registration_endpoint": f"{request_base_url}/register",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
}
# RFC 9728 path-appended discovery for the aggregate /mcp endpoint. A client
# pointed at {base}/mcp inserts the well-known segment before the resource
# path, so this exact route must exist for aggregate discovery to work at all.
# Declared before the parameterized well-known routes below: Starlette matches
# in registration order, and /.well-known/oauth-authorization-server/{name}
# would otherwise capture the "/mcp" suffix as a server name.
@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp")
async def oauth_protected_resource_aggregate(request: Request):
"""
OAuth protected resource discovery for the aggregate /mcp endpoint.
The single-segment ``/mcp`` path does not collide with any per-server PRM pattern
(those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously
describes the aggregate resource.
"""
return _build_aggregate_protected_resource_response(request)
@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/mcp")
async def oauth_authorization_server_aggregate(request: Request):
"""
OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414
path-inserted form for a client that treats {base}/mcp as its authorization base URL.
The single-segment /mcp is reserved for the aggregate so the discovery chain stays
consistent: the aggregate protected-resource document advertises {base}/mcp as its
authorization server, so the document served here must have issuer {base}/mcp. A server
literally named ``mcp`` therefore does not take this route; it keeps its standard
two-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the
per-server row win here instead would serve an issuer of {base} against a resource that
advertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.
"""
return _build_aggregate_authorization_server_response(request)
# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name}
# This is the pattern expected by standard MCP clients (mcp-inspector, VSCode Copilot)
@router.get(
f"/.well-known/oauth-protected-resource{'' if get_server_root_path() == '/' else get_server_root_path()}/mcp/{{mcp_server_name}}"
)
@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{{mcp_server_name}}")
async def oauth_protected_resource_mcp_standard(request: Request, mcp_server_name: str):
"""
OAuth protected resource discovery endpoint using standard MCP URL pattern.
@ -1862,9 +1939,7 @@ async def oauth_protected_resource_mcp_standard(request: Request, mcp_server_nam
# LiteLLM legacy pattern: /.well-known/oauth-protected-resource/{server_name}/mcp
# Kept for backward compatibility with existing deployments
@router.get(
f"/.well-known/oauth-protected-resource{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}/mcp"
)
@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/{{mcp_server_name}}/mcp")
@router.get("/.well-known/oauth-protected-resource")
async def oauth_protected_resource_mcp(request: Request, mcp_server_name: Optional[str] = None):
"""
@ -1934,9 +2009,7 @@ def _build_oauth_authorization_server_response(
# Standard MCP pattern: /.well-known/oauth-authorization-server/mcp/{server_name}
@router.get(
f"/.well-known/oauth-authorization-server{'' if get_server_root_path() == '/' else get_server_root_path()}/mcp/{{mcp_server_name}}"
)
@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/mcp/{{mcp_server_name}}")
async def oauth_authorization_server_mcp_standard(request: Request, mcp_server_name: str):
"""
OAuth authorization server discovery endpoint using standard MCP URL pattern.
@ -1951,9 +2024,7 @@ async def oauth_authorization_server_mcp_standard(request: Request, mcp_server_n
# LiteLLM legacy pattern and root endpoint
@router.get(
f"/.well-known/oauth-authorization-server{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}"
)
@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/{{mcp_server_name}}")
@router.get("/.well-known/oauth-authorization-server")
async def oauth_authorization_server_mcp(request: Request, mcp_server_name: Optional[str] = None):
"""

View file

@ -132,6 +132,18 @@ def get_request_base_url(request: Request) -> str:
return urlunparse((scheme, _strip_default_port(scheme, netloc), parsed.path, "", "", ""))
def well_known_root_suffix() -> str:
"""The ``SERVER_ROOT_PATH`` segment inserted into a ``.well-known`` path (RFC 8414 / 9728
path insertion), empty for a root-mounted proxy or an explicit ``/``.
The discovery route registrations and the 401 challenges that advertise those routes both
derive their path from this one function, so the ``resource_metadata`` URL a client is told
to fetch cannot drift from the route that actually serves it.
"""
root = os.getenv("SERVER_ROOT_PATH", "")
return "" if root == "/" else root
def validate_loopback_redirect_uri(redirect_uri: str) -> None:
"""Require a loopback ``redirect_uri`` (OAuth 2.1 §4.1.2.1 + RFC 8252
§7.3 native-app pattern). MCP clients are native apps that listen on

View file

@ -33,6 +33,7 @@ from litellm.constants import (
LITELLM_DETAILED_TIMING,
LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED,
MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG,
RETURN_RAW_MODEL_NAME_METADATA_KEY,
STREAM_SSE_DATA_PREFIX,
)
from litellm.integrations.custom_guardrail import CustomGuardrail
@ -91,6 +92,13 @@ _CLIENT_DISCONNECTED_ERROR_INFORMATION: StandardLoggingPayloadErrorInformation =
}
def _should_return_raw_model_name(request_data: dict[str, object]) -> bool:
return any(
isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True
for metadata in (request_data.get("metadata"), request_data.get("litellm_metadata"))
)
def _apply_client_disconnect_metadata(target_metadata: Optional[dict[str, object]]) -> None:
if target_metadata is None:
return
@ -672,6 +680,7 @@ def _override_openai_response_model(
response_obj: Any,
requested_model: str,
log_context: str,
return_raw_model_name: bool = False,
) -> None:
"""
Force the OpenAI-compatible `model` field in the response to match what the client requested.
@ -695,7 +704,7 @@ def _override_openai_response_model(
3. If this was a fastest_response batch completion, use the winning model's
model group name instead of the comma-separated list the client sent.
"""
if not requested_model:
if return_raw_model_name or not requested_model:
return
hidden_params = get_hidden_params_dict(response_obj)
@ -1938,6 +1947,7 @@ class ProxyBaseLLMRequestProcessing:
response_obj=response,
requested_model=requested_model_from_client,
log_context=f"litellm_call_id={logging_obj.litellm_call_id}",
return_raw_model_name=_should_return_raw_model_name(self.data),
)
hidden_params = get_hidden_params_dict(response) # get any updated response headers

View file

@ -291,6 +291,7 @@ from litellm.proxy.caching_routes import router as caching_router
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
_is_azure_model_router_request,
_should_return_raw_model_name,
create_response,
)
from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy
@ -7076,6 +7077,9 @@ def _restamp_streaming_chunk_model(
fallback_was_attempted: bool = False,
fallback_model_from_metadata: str | None = None,
) -> tuple[Any, bool]:
if _should_return_raw_model_name(request_data):
return chunk, model_mismatch_logged
target_model = fallback_model_from_metadata if fallback_was_attempted else requested_model_from_client
# Always return the client-requested model name (not provider-prefixed internal identifiers)
# on streaming chunks.

View file

@ -1647,7 +1647,7 @@ async def ui_view_spend_logs(
description="Time till which to view key spend",
),
page: int = fastapi.Query(default=1, description="Page number for pagination", ge=1),
page_size: int = fastapi.Query(default=50, description="Number of items per page", ge=1, le=100),
page_size: int = fastapi.Query(default=50, description="Number of items per page", ge=1, le=1000),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
status_filter: str | None = fastapi.Query(
default=None, description="Filter logs by status (e.g., success, failure)"

View file

@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Literal, Union, cast
from pydantic import BaseModel
from litellm._logging import verbose_router_logger
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import ModelResponse
@ -956,6 +957,12 @@ class ComplexityRouter(CustomLogger):
"""
from litellm.types.router import PreRoutingHookResponse
if self.config.return_raw_model_name:
metadata_key = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata"
metadata = request_kwargs.setdefault(metadata_key, {})
if isinstance(metadata, dict):
metadata[RETURN_RAW_MODEL_NAME_METADATA_KEY] = True
use_session_affinity = self.config.session_affinity and not self.config.plugins
session_id = self._get_session_id_from_request_kwargs(request_kwargs) if use_session_affinity else None
cache_key = self._get_session_affinity_cache_key(session_id, request_kwargs) if session_id is not None else None

View file

@ -311,6 +311,14 @@ class ComplexityRouterConfig(BaseModel):
description="Default model to use if tier cannot be determined",
)
return_raw_model_name: bool = Field(
default=False,
description=(
"Return the resolved raw model name in the response model field instead of "
"the client-requested complexity-router alias"
),
)
# Classifier strategy
classifier_type: Literal["heuristic", "llm"] = Field(
default="heuristic",

View file

@ -14,14 +14,14 @@ shared fixtures build on it.
"""
import functools
import sys
import os
from collections.abc import Iterator
from pathlib import Path
import pytest
import requests
from e2e_config import CONTROL_PLANE_BASE_URL, PROXY_BASE_URL
from e2e_db import RESET_OPT_IN_ENV, reset_spend_logs, run_spend_log_cleanup
from junit_properties import attach_result_properties
from lifecycle import ProxyClientProvider, ResourceManager
from proxy_client import ProxyClient, build_proxy_client
@ -107,26 +107,17 @@ def pytest_runtest_call(item: pytest.Item) -> None:
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""Once the whole e2e session is done (all suites), truncate the spend logs so
the DB doesn't accumulate test rows. Sessions where no e2e test body ran leave
the DB alone so a `DATABASE_URL` pointing at a shared instance is never wiped
without an e2e run. Best-effort: a cleanup failure (no DB reachable) must not
fail the run. The spend_tracking dir goes on sys.path only for this import and
is removed after, so a broader `pytest tests/` run is not left with a mutated
path."""
if not session.stash.get(_E2E_TEST_RAN, False):
return
spend_dir = str(Path(__file__).parent / "quota_management" / "spend_tracking")
sys.path.insert(0, spend_dir)
try:
from spend_e2e_client import reset_spend_logs # pyright: ignore
reset_spend_logs()
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
print(f"spend-log cleanup best-effort failed: {exc}")
finally:
if spend_dir in sys.path:
sys.path.remove(spend_dir)
"""Once the whole e2e session is done (all suites), optionally truncate the
spend logs so the DB doesn't accumulate test rows. The truncate is destructive
and irreversible, so it runs only when the operator explicitly opts in
(`E2E_RESET_SPEND_LOGS=1`) and an e2e test body actually ran; otherwise a
`DATABASE_URL` pointing at a shared or staging instance is left untouched.
Best-effort: a cleanup failure (no DB reachable) must not fail the run."""
run_spend_log_cleanup(
opt_in=os.environ.get(RESET_OPT_IN_ENV),
e2e_test_ran=session.stash.get(_E2E_TEST_RAN, False),
truncate=reset_spend_logs,
)
@pytest.fixture(scope="session")

56
tests/e2e/e2e_db.py Normal file
View file

@ -0,0 +1,56 @@
"""Shared, destructive DB helpers for the e2e harness.
Kept at the top level next to e2e_config and lifecycle so every suite imports it
by name (`from e2e_db import ...`); no suite reaches into another's directory by
mutating sys.path.
reset_spend_logs truncates LiteLLM_SpendLogs and cannot be undone, so the
session-finish cleanup routes through run_spend_log_cleanup, which fires the
truncate only on an explicit operator opt-in. "An e2e test ran" is necessary but
never sufficient: a DATABASE_URL pointing at a shared or staging instance must
not be wiped by a routine local run that merely exercised a test.
"""
import os
from collections.abc import Callable
RESET_OPT_IN_ENV = "E2E_RESET_SPEND_LOGS"
def run_spend_log_cleanup(
*, opt_in: str | None, e2e_test_ran: bool, truncate: Callable[[], None]
) -> bool:
"""Invoke `truncate` iff the destructive spend-log reset is both opted into
and warranted, returning whether the truncate was attempted.
The truncate fires only when the opt-in value is exactly "1" AND an e2e test
body actually ran. Any other opt-in value (unset, "0", "true", "") leaves the
DB untouched, so the destructive path is never armed by the env var's mere
presence or by a test run on its own. Best-effort: a truncate failure is
swallowed so cleanup never fails the session, so the returned bool reports
that the reset was attempted, not that the DB call succeeded.
"""
if opt_in != "1" or not e2e_test_ran:
return False
try:
truncate()
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
print(f"spend-log cleanup best-effort failed: {exc}")
return True
def reset_spend_logs() -> None:
"""Truncate LiteLLM_SpendLogs for a clean slate. No proxy endpoint deletes
spend logs (/global/spend/reset keeps them), so go to the DB directly. Uses
DATABASE_URL (default: the local docker postgres on its mapped host port; the
in-container `@db` host isn't resolvable from the host, so default to
localhost).
"""
import psycopg
url = os.environ.get(
"DATABASE_URL",
"postgresql://llmproxy:dbpassword9090@localhost:5432/litellm",
)
with psycopg.connect(url) as conn:
_ = conn.execute('TRUNCATE TABLE "LiteLLM_SpendLogs"')

View file

@ -11,7 +11,6 @@ helpers from one place.
from __future__ import annotations
import os
import time
from collections.abc import Callable
from dataclasses import dataclass
@ -50,7 +49,6 @@ from models import (
__all__ = [
"SpendClient",
"build_client",
"reset_spend_logs",
"unique_marker",
"unwrap",
"is_ok",
@ -59,23 +57,6 @@ __all__ = [
]
def reset_spend_logs() -> None:
"""Truncate LiteLLM_SpendLogs for a clean slate. No proxy endpoint deletes
spend logs (/global/spend/reset keeps them), so go to the DB directly. Uses
DATABASE_URL (default: the local docker postgres on its mapped host port; note
the in-container `@db` host isn't resolvable from the host, so default to
localhost).
"""
import psycopg
url = os.environ.get(
"DATABASE_URL",
"postgresql://llmproxy:dbpassword9090@localhost:5432/litellm",
)
with psycopg.connect(url) as conn:
_ = conn.execute('TRUNCATE TABLE "LiteLLM_SpendLogs"')
def _chat_body(
model: str,
content: str,

View file

@ -456,7 +456,7 @@ class TestLangfuseOtelKeyDynamicConfig:
import base64
expected_auth = base64.b64encode(b"key_public:key_secret").decode()
assert config.headers == f"Authorization=Basic {expected_auth}"
assert config.headers == f"Authorization=Basic {expected_auth},x-langfuse-ingestion-version=4"
def test_construct_dynamic_otel_config_host_without_protocol(self):
with self._clean_env():
@ -521,7 +521,10 @@ class TestLangfuseOtelKeyDynamicConfig:
import base64
expected_auth = base64.b64encode(b"key_public:key_secret").decode()
assert exporter._headers == {"Authorization": f"Basic {expected_auth}"}
assert exporter._headers == {
"Authorization": f"Basic {expected_auth}",
"x-langfuse-ingestion-version": "4",
}
def test_key_dynamic_params_reuse_cached_provider(self):
with self._clean_env():
@ -574,7 +577,10 @@ class TestLangfuseOtelKeyDynamicConfig:
provider = next(iter(logger._tracer_provider_cache.values()))
exporter = provider._active_span_processor._span_processors[0].span_exporter
assert isinstance(exporter, OTLPSpanExporter)
assert exporter._headers == {"Authorization": f"Basic {secret}"}
assert exporter._headers == {
"Authorization": f"Basic {secret}",
"x-langfuse-ingestion-version": "4",
}
class TestLangfuseOtelResponsesAPI:

View file

@ -123,6 +123,90 @@ def test_validate_environment_preserves_explicit_session_affinity_header():
assert headers["x-session-affinity"] == "explicit-session"
def test_validate_environment_sets_json_content_type():
config = FireworksAIConfig()
headers = config.validate_environment(
headers={},
model="accounts/fireworks/models/test-model",
messages=[],
optional_params={},
litellm_params={},
api_key="test-key",
)
assert headers["Content-Type"] == "application/json"
def test_validate_environment_preserves_explicit_content_type():
config = FireworksAIConfig()
headers = config.validate_environment(
headers={"content-type": "multipart/form-data"},
model="accounts/fireworks/models/test-model",
messages=[],
optional_params={},
litellm_params={},
api_key="test-key",
)
assert headers["content-type"] == "multipart/form-data"
assert "Content-Type" not in headers
def test_validate_environment_sets_json_content_type_with_session_affinity():
config = FireworksAIConfig()
headers = config.validate_environment(
headers={},
model="accounts/fireworks/models/test-model",
messages=[],
optional_params={},
litellm_params={"litellm_session_id": "session-123"},
api_key="test-key",
)
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"] == "Bearer test-key"
assert headers["x-session-affinity"] == "session-123"
def test_validate_environment_resolves_api_key_from_env_and_sets_content_type(monkeypatch):
monkeypatch.setenv("FIREWORKS_API_KEY", "fw-env-key")
config = FireworksAIConfig()
headers = config.validate_environment(
headers={},
model="accounts/fireworks/models/test-model",
messages=[],
optional_params={},
litellm_params={},
)
assert headers["Authorization"] == "Bearer fw-env-key"
assert headers["Content-Type"] == "application/json"
def test_validate_environment_raises_without_api_key(monkeypatch):
for env_var in (
"FIREWORKS_API_KEY",
"FIREWORKS_AI_API_KEY",
"FIREWORKSAI_API_KEY",
"FIREWORKS_AI_TOKEN",
):
monkeypatch.delenv(env_var, raising=False)
config = FireworksAIConfig()
with pytest.raises(ValueError, match="FIREWORKS_API_KEY is not set"):
config.validate_environment(
headers={},
model="accounts/fireworks/models/test-model",
messages=[],
optional_params={},
litellm_params={},
)
def test_get_fireworks_session_id_prefers_litellm_session_id_over_trace_id():
assert (
get_fireworks_session_id(

View file

@ -6131,3 +6131,133 @@ class TestMCPDcrBridgeDelegateAdmission:
route="/mcp/bridge_delegate_server",
)
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
class TestAggregateGatewayDcrChallenge:
"""The mcp_gateway_dcr front door: a 401 on the aggregate /mcp scope must
carry the RFC 9728 resource_metadata challenge pointing at the gateway's
own protected-resource metadata, and must NOT fire for named-server
targets, explicit litellm keys, or non-401 failures."""
_AUTH_PATCH_TARGET = "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth"
_EXPECTED_RESOURCE_METADATA = 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp"'
def _scope(self, path="/mcp", extra_headers=()):
return {
"type": "http",
"method": "POST",
"path": path,
"headers": [(b"host", b"testserver"), *extra_headers],
}
def _auth_401(self):
async def _raise(api_key, request):
raise ProxyException(
message="Authentication Error: Invalid API key",
type="auth_error",
param="api_key",
code=401,
)
return _raise
async def test_challenge_on_anonymous_aggregate_mcp(self):
"""Anonymous request to the aggregate /mcp: 401 plus
the bare bearer challenge (no error attribute, RFC 6750 section 3.1)."""
with (
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(self._scope())
assert exc_info.value.status_code == 401
www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"]
assert www_authenticate == f"Bearer {self._EXPECTED_RESOURCE_METADATA}"
async def test_challenge_invalid_token_on_failed_bearer(self):
"""A bearer that fails LiteLLM admission at aggregate scope (an expired
gateway session, a revoked key) re-challenges with error=invalid_token
so a spec client re-authorizes instead of retrying the dead token."""
with (
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(
self._scope(extra_headers=((b"authorization", b"Bearer expired-session-token"),))
)
assert exc_info.value.status_code == 401
www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"]
assert www_authenticate == f'Bearer error="invalid_token", {self._EXPECTED_RESOURCE_METADATA}'
async def test_challenge_inserts_server_root_path(self):
"""With SERVER_ROOT_PATH set the resource_metadata URL must carry the same path-inserted
root segment the aggregate PRM route is registered with (both derive it from
well_known_root_suffix), so a DCR client behind a sub-path is pointed at a route that
exists instead of a 404. Regression: the challenge used to hard-code /mcp and omit the
root path the route inserts."""
import os
with (
patch.dict(os.environ, {"SERVER_ROOT_PATH": "/litellm"}),
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(self._scope())
www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"]
assert 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/litellm/mcp"' in www_authenticate
async def test_no_challenge_for_explicit_litellm_key(self):
"""An explicit x-litellm-api-key declares a litellm-key client; a typo
there must surface the real auth error, never a DCR challenge that
would send SDKs into a sign-in flow."""
with (
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(ProxyException):
await MCPRequestHandler.process_mcp_request(
self._scope(extra_headers=((b"x-litellm-api-key", b"sk-typo"),))
)
async def test_no_challenge_for_named_servers_header(self):
"""x-mcp-servers names explicit targets; the per-server challenge paths
own those, so the aggregate challenge must not fire."""
with (
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(ProxyException):
await MCPRequestHandler.process_mcp_request(
self._scope(extra_headers=((b"x-mcp-servers", b"github"),))
)
async def test_no_challenge_for_path_named_server(self):
"""/mcp/{server} targets one server; the aggregate challenge must not
fire even when that server does not resolve."""
with (
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(ProxyException):
await MCPRequestHandler.process_mcp_request(self._scope(path="/mcp/github"))
async def test_no_challenge_for_client_supplied_mcp_auth(self):
"""Per-server x-mcp-{alias}-authorization headers mean the caller is
not a cold-start DCR client; keep the original error."""
with (
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
):
with pytest.raises(ProxyException):
await MCPRequestHandler.process_mcp_request(
self._scope(extra_headers=((b"x-mcp-github-authorization", b"Bearer upstream"),))
)
async def test_no_challenge_for_non_401_failure(self):
"""Only genuine 401s convert to a challenge; a 500 stays a 500."""
async def _raise_500(api_key, request):
raise ProxyException(message="boom", type="server_error", param=None, code=500)
with (
patch(self._AUTH_PATCH_TARGET, side_effect=_raise_500),
):
with pytest.raises(ProxyException) as exc_info:
await MCPRequestHandler.process_mcp_request(self._scope())
assert str(exc_info.value.code) == "500"

View file

@ -0,0 +1,22 @@
import os
import pytest
@pytest.fixture(autouse=True)
def _hermetic_server_root_path():
"""Isolate MCP discovery tests from a leaked ``SERVER_ROOT_PATH``.
``tests/test_litellm/proxy/test_custom_proxy.py`` sets ``SERVER_ROOT_PATH`` at import time
(its app mounts under a custom path) and never restores it, so in a shared shard the value
leaks into this process. The discovery routes and the 401 challenges read it, so a leaked
value would silently rewrite every ``resource_metadata`` URL and make these tests depend on
shard ordering. Clearing it here pins the default (root-mounted) deployment; a test that
exercises a sub-path deployment sets the value explicitly within its own body.
"""
saved = os.environ.pop("SERVER_ROOT_PATH", None)
try:
yield
finally:
if saved is not None:
os.environ["SERVER_ROOT_PATH"] = saved

View file

@ -7500,3 +7500,131 @@ async def test_reload_servers_from_database_hydrates_dcr_clients():
await global_mcp_server_manager.reload_servers_from_database()
hydrate_spy.assert_awaited_once()
def test_aggregate_wellknown_routes_serve_gateway_metadata():
"""Both path-appended aggregate routes serve the gateway documents. Exercises real
routing, so this also pins registration order: the parameterized
/.well-known/oauth-authorization-server/{name} route would otherwise capture the /mcp
suffix as a server name."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
app = FastAPI()
app.include_router(router)
client = TestClient(app)
prm = client.get("/.well-known/oauth-protected-resource/mcp")
asm = client.get("/.well-known/oauth-authorization-server/mcp")
assert prm.status_code == 200
assert prm.json()["resource"] == "http://testserver/mcp"
assert prm.json()["authorization_servers"] == ["http://testserver/mcp"]
assert asm.status_code == 200
assert asm.json()["issuer"] == "http://testserver/mcp"
assert asm.json()["authorization_endpoint"] == "http://testserver/authorize"
assert "none" in asm.json()["token_endpoint_auth_methods_supported"]
def test_as_aggregate_route_reserves_mcp_for_the_aggregate():
"""The single-segment /.well-known/oauth-authorization-server/mcp is reserved for the
aggregate even when a server is literally named ``mcp``. The aggregate protected-resource
document advertises {base}/mcp as its authorization server, so the document served here
must carry issuer {base}/mcp for the RFC 8414 issuer check to pass. Letting the per-server
row win (issuer {base}) breaks that chain, so the aggregate wins and the mcp-named server
keeps its standard two-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
server_named_mcp = _create_oauth2_server(server_id="mcp_srv", name="mcp", server_name="mcp", alias="mcp")
global_mcp_server_manager.registry[server_named_mcp.server_id] = server_named_mcp
app = FastAPI()
app.include_router(router)
client = TestClient(app)
try:
asm = client.get("/.well-known/oauth-authorization-server/mcp")
assert asm.status_code == 200
# the aggregate document, whose issuer matches what the aggregate PRM advertises
assert asm.json()["issuer"] == "http://testserver/mcp"
prm = client.get("/.well-known/oauth-protected-resource/mcp")
assert prm.status_code == 200
assert prm.json()["authorization_servers"] == [asm.json()["issuer"]]
# the mcp-named server keeps its own document on the standard two-segment route
per_server = client.get("/.well-known/oauth-authorization-server/mcp/mcp")
assert per_server.status_code == 200
assert "/mcp/authorize" in per_server.json()["authorization_endpoint"]
finally:
global_mcp_server_manager.registry.clear()
def test_well_known_root_suffix_reflects_server_root_path():
"""The single path segment both the discovery routes and the 401 challenges insert for RFC
8414/9728 path insertion: empty for a root-mounted proxy or an explicit ``/``, the configured
path otherwise. Sharing this one function is what keeps the advertised resource_metadata URL
equal to the route that serves it."""
import os
from unittest.mock import patch
from litellm.proxy._experimental.mcp_server.oauth_utils import well_known_root_suffix
with patch.dict(os.environ, {"SERVER_ROOT_PATH": ""}):
assert well_known_root_suffix() == ""
with patch.dict(os.environ, {"SERVER_ROOT_PATH": "/"}):
assert well_known_root_suffix() == ""
with patch.dict(os.environ, {"SERVER_ROOT_PATH": "/litellm"}):
assert well_known_root_suffix() == "/litellm"
@pytest.mark.asyncio
async def test_bare_origin_discovery_resolves_single_server_not_aggregate():
"""The always-on aggregate front door must not change bare-origin discovery: with one
oauth2 server configured, the no-suffix /.well-known/oauth-{authorization-server,
protected-resource} still resolves THAT server, so an existing single-server deployment's
discovery is unchanged. The aggregate document lives only at the /mcp-suffixed routes."""
from fastapi import Request
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
_build_oauth_protected_resource_response,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
authorization_response = _build_oauth_authorization_server_response(
request=mock_request, mcp_server_name=None
)
resource_response = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name=None, use_standard_pattern=True
)
# per-server, not aggregate: the single server's name is in the endpoints
assert "/test_oauth/authorize" in authorization_response["authorization_endpoint"]
assert authorization_response["issuer"] == "https://llm.example.com"
assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"]
finally:
global_mcp_server_manager.registry.clear()

View file

@ -19,6 +19,7 @@ import json
import pytest
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.proxy_server import (
@ -272,6 +273,21 @@ def test_restamp_streaming_chunk_model_overrides_model_on_basemodel():
assert snapshot == {"model": "gpt-4", "logged": True, "same_object": True}
@pytest.mark.parametrize("return_raw_model_name", [False, True])
def test_restamp_streaming_chunk_model_respects_raw_model_name_toggle(return_raw_model_name):
chunk = _simple_chunk(model="gpt-4o-mini")
new_chunk, logged = _restamp_streaming_chunk_model(
chunk=chunk,
requested_model_from_client="auto_router/complexity_router",
request_data={"metadata": {RETURN_RAW_MODEL_NAME_METADATA_KEY: return_raw_model_name}},
model_mismatch_logged=False,
)
expected_model = "gpt-4o-mini" if return_raw_model_name else "auto_router/complexity_router"
assert new_chunk.model == expected_model
assert logged is (not return_raw_model_name)
def test_restamp_streaming_chunk_model_overrides_model_on_dict():
chunk = {"model": "internal", "choices": []}
new_chunk, logged = _restamp_streaming_chunk_model(

View file

@ -1467,6 +1467,59 @@ async def test_ui_view_spend_logs_pagination(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.parametrize(
"page_size, expected_status, expected_rows",
[
(1000, 200, 1000),
(1001, 422, None),
],
)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_page_size_upper_bound(
client, monkeypatch, page_size, expected_status, expected_rows
):
mock_spend_logs = [
{
"id": f"log{i}",
"request_id": f"req{i}",
"api_key": "sk-test-key",
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
}
for i in range(1200)
]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, lambda where: mock_spend_logs),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/v2",
params={
"page": 1,
"page_size": page_size,
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == expected_status
if expected_status == 200:
data = response.json()
assert data["page_size"] == page_size
assert len(data["data"]) == expected_rows
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
mock_spend_logs = [

View file

@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
import litellm
from litellm._uuid import uuid
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.opentelemetry import UserAPIKeyAuth
from litellm.proxy.common_request_processing import (
@ -27,6 +28,7 @@ from litellm.proxy.common_request_processing import (
_is_azure_model_router_request,
_override_openai_response_model,
_parse_event_data_for_error,
_should_return_raw_model_name,
_UpstreamClosingStreamingResponse,
create_response,
)
@ -1675,6 +1677,31 @@ class TestExtractErrorFromSSEChunk:
class TestOverrideOpenAIResponseModel:
"""Tests for _override_openai_response_model function"""
@pytest.mark.parametrize("return_raw_model_name", [False, True])
def test_raw_model_name_toggle(self, return_raw_model_name):
response_obj = {"model": "gpt-4o-mini"}
_override_openai_response_model(
response_obj=response_obj,
requested_model="auto_router/complexity_router",
log_context="test_context",
return_raw_model_name=return_raw_model_name,
)
expected_model = "gpt-4o-mini" if return_raw_model_name else "auto_router/complexity_router"
assert response_obj["model"] == expected_model
@pytest.mark.parametrize(
"request_data, expected",
[
({"metadata": {}}, False),
({"metadata": {RETURN_RAW_MODEL_NAME_METADATA_KEY: True}}, True),
({"litellm_metadata": {RETURN_RAW_MODEL_NAME_METADATA_KEY: True}}, True),
],
)
def test_raw_model_name_toggle_metadata(self, request_data, expected):
assert _should_return_raw_model_name(request_data) is expected
def test_override_model_preserves_fallback_model_when_fallback_occurred_object(
self,
):
@ -3203,8 +3230,6 @@ class TestDisconnectGatherCleanup:
async def test_base_process_llm_request_preserves_llm_error_after_gather(
self, monkeypatch
):
import asyncio
import litellm.proxy.common_request_processing as cpr
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing

View file

@ -20,6 +20,7 @@ import litellm
from litellm import Router
from litellm._logging import verbose_router_logger
from litellm.caching.dual_cache import DualCache
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.router_strategy.complexity_router.complexity_router import (
ComplexityRouter,
DimensionScore,
@ -125,6 +126,29 @@ class TestComplexityRouterInit:
)
assert router.config.default_model == "fallback-model"
@pytest.mark.asyncio
@pytest.mark.parametrize("return_raw_model_name", [False, True])
async def test_pre_routing_hook_propagates_raw_model_response_setting(
self, mock_router_instance, basic_config, return_raw_model_name
):
config = {**basic_config, "return_raw_model_name": return_raw_model_name}
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
request_kwargs = {}
result = await router.async_pre_routing_hook(
model="test-router",
request_kwargs=request_kwargs,
messages=[{"role": "user", "content": "Hello"}],
)
assert result is not None
metadata = request_kwargs.get("metadata", {})
assert metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY, False) is return_raw_model_name
class TestTokenScoring:
"""Test token count scoring."""

View file

@ -12,6 +12,7 @@ from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
from litellm.integrations.opentelemetry import OpenTelemetry
from litellm.types.services import ServiceTypes
from litellm._service_logger import ServiceLogging
from litellm.types.utils import StandardCallbackDynamicParams
class TestServiceLoggerOTEL(unittest.IsolatedAsyncioTestCase):
@ -108,6 +109,44 @@ class TestServiceLoggerOTEL(unittest.IsolatedAsyncioTestCase):
"Generic OTEL logger should have received the log exactly once.",
)
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing")
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics")
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs")
async def test_langfuse_otel_env_config_includes_v4_ingestion_header(
self, mock_logs, mock_metrics, mock_tracing
):
logger = LangfuseOtelLogger()
headers = OpenTelemetry._get_headers_dictionary(logger.config.headers)
self.assertEqual(
headers["x-langfuse-ingestion-version"],
"4",
)
self.assertTrue(headers["Authorization"].startswith("Basic "))
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing")
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics")
@patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs")
async def test_langfuse_otel_dynamic_headers_include_v4_ingestion_header(
self, mock_logs, mock_metrics, mock_tracing
):
logger = LangfuseOtelLogger()
headers = logger.construct_dynamic_otel_headers(
StandardCallbackDynamicParams(
langfuse_public_key="pk-lf-dynamic",
langfuse_secret_key="sk-lf-dynamic",
)
)
self.assertIsNotNone(headers)
self.assertEqual(
headers["x-langfuse-ingestion-version"],
"4",
)
self.assertTrue(headers["Authorization"].startswith("Basic "))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,28 @@
"use client";
import React from "react";
import { Form } from "antd";
import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
interface AutorouterTabProps {
accessToken: string | null;
userId: string | null;
userRole: string;
}
const AutorouterTab: React.FC<AutorouterTabProps> = ({ accessToken, userRole }) => {
const [form] = Form.useForm();
if (!accessToken) {
return null;
}
return (
<div className="w-full">
<AddAutoRouterTab form={form} handleOk={() => form.resetFields()} accessToken={accessToken} userRole={userRole} />
</div>
);
};
export default AutorouterTab;

View file

@ -1,109 +1,34 @@
import { render } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
const mockUsePaginatedDailyActivity = vi.fn();
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
usePaginatedDailyActivity: (args: unknown) => mockUsePaginatedDailyActivity(args),
}));
vi.mock("@/components/networking", () => ({
userDailyActivityCall: vi.fn(),
}));
vi.mock("@/components/shared/advanced_date_picker", () => ({
__esModule: true,
default: () => <div data-testid="date-picker" />,
}));
vi.mock("@/components/shared/charts", () => ({
AreaChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
<div data-testid="area-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
),
DonutChart: ({ data, label }: { data: unknown; label: string }) => (
<div data-testid="donut-chart" data-label={label} data-slices={JSON.stringify(data)} />
),
}));
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () => <div data-testid="autorouter-tab" /> }));
vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () => <div data-testid="caching-tab" /> }));
import CostOptimizationView from "./CostOptimizationView";
const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
...overrides,
});
const day = (date: string, metrics: Partial<SpendMetrics>): DailyData => ({
date,
metrics: baseMetrics(metrics),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
});
const renderWith = (results: DailyData[]) => {
mockUsePaginatedDailyActivity.mockReturnValue({ data: { results }, loading: false, isFetchingMore: false });
return render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
};
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
describe("CostOptimizationView", () => {
it("sums compression and caching dollars across days into the summary cards", () => {
const { getByText } = renderWith([
day("2026-07-12", {
compression_savings_spend: 0.04,
prompt_caching_savings_spend: 0.006,
compression_saved_tokens: 40000,
}),
day("2026-07-13", {
compression_savings_spend: 0.1,
prompt_caching_savings_spend: 0.01,
compression_saved_tokens: 100000,
}),
]);
it("renders all four cost-optimization tabs", () => {
const { getByText } = renderView();
// compression 0.14 + caching 0.016 = 0.156
expect(getByText("$0.1560")).toBeInTheDocument();
expect(getByText("$0.1400")).toBeInTheDocument();
expect(getByText("$0.0160")).toBeInTheDocument();
expect(getByText("140,000 tokens compressed")).toBeInTheDocument();
expect(getByText("Usage")).toBeInTheDocument();
expect(getByText("Prompt Compression")).toBeInTheDocument();
expect(getByText("Autorouter")).toBeInTheDocument();
expect(getByText("Prompt Caching")).toBeInTheDocument();
});
it("builds a per-day time series and per-driver donut from the daily rows", () => {
const { getByTestId } = renderWith([
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
]);
it("defaults to the Usage tab and switches the active tab on click", () => {
const { getByRole } = renderView();
const series = JSON.parse(getByTestId("area-chart").getAttribute("data-series") ?? "[]");
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.01 });
expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "true");
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "false");
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([
{ driver: "Compression", usd: expect.closeTo(0.14, 5) },
{ driver: "Prompt caching", usd: expect.closeTo(0.016, 5) },
]);
});
fireEvent.click(getByRole("tab", { name: "Prompt Compression" }));
it("omits a driver slice when that driver has no savings", () => {
const { getByTestId } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })]);
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([{ driver: "Compression", usd: expect.closeTo(0.04, 5) }]);
expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "false");
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true");
});
});

View file

@ -1,16 +1,13 @@
"use client";
import React, { useMemo, useState } from "react";
import React from "react";
import { PiggyBank } from "lucide-react";
import { Alert, Tabs } from "antd";
import { AreaChart, DonutChart } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { userDailyActivityCall } from "@/components/networking";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles } from "@/utils/roles";
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
import UsageTab from "./UsageTab";
import PromptCompressionTab from "./PromptCompressionTab";
import AutorouterTab from "./AutorouterTab";
import PromptCachingTab from "./PromptCachingTab";
interface CostOptimizationViewProps {
accessToken: string | null;
@ -18,138 +15,62 @@ interface CostOptimizationViewProps {
userRole: string;
}
type DateRange = { from?: Date; to?: Date };
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const usd = (value: number): string => {
const decimals = value > 0 && value < 1 ? 4 : 2;
return `$${formatNumberWithCommas(value, decimals)}`;
};
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?? 0;
const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0;
const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0;
const SummaryCard = ({ label, value, hint }: { label: string; value: string; hint?: string }) => (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []);
const initialTo = useMemo(() => new Date(), []);
const [dateValue, setDateValue] = useState<DateRange>({ from: initialFrom, to: initialTo });
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
const isAdmin = all_admin_roles.includes(userRole);
const effectiveUserId = isAdmin ? null : userId;
const { data, loading, isFetchingMore } = usePaginatedDailyActivity({
fetchFn: userDailyActivityCall,
args: [accessToken, startTime, endTime, effectiveUserId],
enabled: !!accessToken && !!startTime && !!endTime,
});
const results = data.results as DailyData[];
const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]);
const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]);
const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]);
const totalSaved = compressionTotal + cachingTotal;
const overTime = useMemo(
() =>
results.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[results],
);
const byDriver = useMemo(
() =>
[
{ driver: "Compression", usd: compressionTotal },
{ driver: "Prompt caching", usd: cachingTotal },
].filter((d) => d.usd > 0),
[compressionTotal, cachingTotal],
);
const items = [
{
key: "usage",
label: "Usage",
children: <UsageTab accessToken={accessToken} userId={userId} userRole={userRole} />,
},
{
key: "compression",
label: "Prompt Compression",
children: <PromptCompressionTab accessToken={accessToken} />,
},
{
key: "autorouter",
label: "Autorouter",
children: <AutorouterTab accessToken={accessToken} userId={userId} userRole={userRole} />,
},
{
key: "caching",
label: "Prompt Caching",
children: <PromptCachingTab accessToken={accessToken} />,
},
];
return (
<div className="w-full space-y-6 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<PiggyBank className="size-6 text-emerald-600" strokeWidth={1.75} />
<h1 className="text-xl font-semibold text-foreground">Cost Optimization</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Money saved by prompt compression and prompt caching across your requests
</p>
<div>
<div className="flex items-center gap-2">
<PiggyBank className="size-6 text-emerald-600" strokeWidth={1.75} />
<h1 className="text-xl font-semibold text-foreground">Cost Optimization</h1>
</div>
<AdvancedDatePicker value={dateValue} onValueChange={(v) => setDateValue(v)} />
<p className="mt-1 text-sm text-muted-foreground">
Track and configure the mechanisms that save you money: prompt compression, prompt caching, and auto routing
</p>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<SummaryCard
label="Total saved"
value={usd(totalSaved)}
hint={loading || isFetchingMore ? "Loading..." : "Compression + prompt caching"}
/>
<SummaryCard
label="Compression savings"
value={usd(compressionTotal)}
hint={`${formatNumberWithCommas(savedTokensTotal)} tokens compressed`}
/>
<SummaryCard label="Prompt caching savings" value={usd(cachingTotal)} hint="Cache read discount" />
</div>
<Alert
type="info"
showIcon
message="This is an experimental dashboard"
description={
<span>
Have feedback? Join the discussion{" "}
<a
href="https://github.com/BerriAI/litellm/discussions/32172"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
here
</a>
</span>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Savings over time</CardTitle>
</CardHeader>
<CardContent>
<AreaChart
data={overTime}
index="date"
categories={["Compression", "Prompt caching"]}
colors={["emerald", "blue"]}
valueFormatter={usd}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Savings by driver</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
className="h-80"
data={byDriver}
index="driver"
category="usd"
colors={["emerald", "blue"]}
valueFormatter={usd}
showLabel
label={usd(totalSaved)}
/>
</CardContent>
</Card>
</div>
<Tabs defaultActiveKey="usage" items={items} />
</div>
);
};

View file

@ -0,0 +1,52 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { getGeneralSettingsCall } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import {
PromptCachingPanel,
generalSettingsItem,
} from "@/app/(dashboard)/router-settings/_components/general_settings";
interface PromptCachingTabProps {
accessToken: string | null;
}
const PromptCachingTab: React.FC<PromptCachingTabProps> = ({ accessToken }) => {
const [settings, setSettings] = useState<generalSettingsItem[]>([]);
const loadSettings = useCallback(() => {
if (!accessToken) {
return;
}
getGeneralSettingsCall(accessToken)
.then((data: generalSettingsItem[]) => setSettings(data))
.catch((error) => {
console.error("Failed to load prompt caching settings:", error);
NotificationsManager.fromBackend("Failed to load prompt caching settings");
});
}, [accessToken]);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const handleChange = (fieldName: string, newValue: unknown) => {
setSettings((prev) =>
prev.map((setting) => (setting.field_name === fieldName ? { ...setting, field_value: newValue } : setting)),
);
};
if (!accessToken) {
return null;
}
return (
<div className="w-full">
<PromptCachingPanel accessToken={accessToken} settings={settings} onChange={handleChange} />
</div>
);
};
export default PromptCachingTab;

View file

@ -0,0 +1,176 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Button, Form, Input, Switch } from "antd";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import {
buildCompressionGuardrailPayload,
compressionGuardrailsOf,
GuardrailListItem,
GuardrailListResponse,
} from "./helpers";
interface PromptCompressionTabProps {
accessToken: string | null;
}
interface CompressionFormValues {
name: string;
apiBase: string;
defaultOn: boolean;
}
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken }) => {
const [form] = Form.useForm<CompressionFormValues>();
const [guardrails, setGuardrails] = useState<GuardrailListItem[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const loadGuardrails = useCallback(() => {
if (!accessToken) {
return;
}
getGuardrailsList(accessToken)
.then((response) => setGuardrails(compressionGuardrailsOf(response as GuardrailListResponse)))
.catch((error) => {
console.error("Failed to load compression guardrails:", error);
NotificationsManager.fromBackend("Failed to load compression guardrails");
})
.finally(() => setIsLoading(false));
}, [accessToken]);
useEffect(() => {
loadGuardrails();
}, [loadGuardrails]);
const handleAdd = async (values: CompressionFormValues) => {
if (!accessToken) {
return;
}
setIsSaving(true);
try {
await createGuardrailCall(
accessToken,
buildCompressionGuardrailPayload({
name: values.name,
apiBase: values.apiBase,
defaultOn: values.defaultOn ?? true,
}),
);
NotificationsManager.success("Compression guardrail created");
form.resetFields();
await loadGuardrails();
} catch (error) {
console.error("Failed to create compression guardrail:", error);
NotificationsManager.fromBackend("Failed to create compression guardrail");
} finally {
setIsSaving(false);
}
};
return (
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>Headroom prompt compression</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-sm text-muted-foreground">
Headroom is a native LiteLLM guardrail that compresses your prompts before they reach the model, so you pay
for fewer input tokens. The tokens it removes are priced and shown on the Usage tab as compression savings.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/headroom"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
Headroom setup docs
</a>
</p>
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{!isLoading && guardrails.length === 0 && (
<p className="text-sm text-muted-foreground">
No prompt compression guardrails configured yet. Add one below to start saving on input tokens
</p>
)}
{!isLoading && guardrails.length > 0 && (
<ul className="divide-y divide-gray-200">
{guardrails.map((guardrail) => (
<li key={guardrail.guardrail_id} className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium text-foreground">{guardrail.guardrail_name}</p>
<p className="text-xs text-muted-foreground">{guardrail.litellm_params?.api_base ?? ""}</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
guardrail.litellm_params?.default_on
? "bg-emerald-100 text-emerald-800"
: "bg-gray-100 text-gray-600"
}`}
>
{guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Add Headroom compression guardrail</CardTitle>
</CardHeader>
<CardContent>
<Form
form={form}
layout="vertical"
requiredMark={false}
onFinish={handleAdd}
initialValues={{ defaultOn: true }}
>
<Form.Item name="name" label="Name" rules={[{ required: true, message: "Name is required" }]}>
<Input placeholder="headroom-compression" />
</Form.Item>
<Form.Item
name="apiBase"
label="Headroom API base"
tooltip="Base URL of your Headroom compression service (LiteLLM calls its /v1/compress endpoint)"
extra="The URL where your Headroom compression service is hosted"
rules={[{ required: true, message: "API base is required" }]}
>
<Input placeholder="https://your-headroom-endpoint" />
</Form.Item>
<Form.Item name="defaultOn" label="Apply to all requests" valuePropName="checked">
<Switch />
</Form.Item>
<div className="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3">
<p className="text-sm text-yellow-800">
Applying compression to all requests is available to all users. Enabling it selectively per key or team
is a LiteLLM Enterprise feature. Get a trial key{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
here
</a>
</p>
</div>
<div className="flex justify-end">
<Button type="primary" htmlType="submit" loading={isSaving}>
Add guardrail
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
);
};
export default PromptCompressionTab;

View file

@ -0,0 +1,108 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
const mockUsePaginatedDailyActivity = vi.fn();
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
usePaginatedDailyActivity: (args: unknown) => mockUsePaginatedDailyActivity(args),
}));
vi.mock("@/components/networking", () => ({
userDailyActivityCall: vi.fn(),
}));
vi.mock("@/components/shared/advanced_date_picker", () => ({
__esModule: true,
default: () => <div data-testid="date-picker" />,
}));
vi.mock("@/components/shared/charts", () => ({
AreaChart: ({ data, categories }: { data: unknown; categories: string[] }) => (
<div data-testid="area-chart" data-categories={categories.join(",")} data-series={JSON.stringify(data)} />
),
DonutChart: ({ data, label }: { data: unknown; label: string }) => (
<div data-testid="donut-chart" data-label={label} data-slices={JSON.stringify(data)} />
),
}));
import UsageTab from "./UsageTab";
const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
...overrides,
});
const day = (date: string, metrics: Partial<SpendMetrics>): DailyData => ({
date,
metrics: baseMetrics(metrics),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
});
const renderWith = (results: DailyData[]) => {
mockUsePaginatedDailyActivity.mockReturnValue({ data: { results }, loading: false, isFetchingMore: false });
return render(<UsageTab accessToken="test-token" userId="u1" userRole="proxy_admin" />);
};
describe("UsageTab", () => {
it("sums compression and caching dollars across days into the summary cards", () => {
const { getByText } = renderWith([
day("2026-07-12", {
compression_savings_spend: 0.04,
prompt_caching_savings_spend: 0.006,
compression_saved_tokens: 40000,
}),
day("2026-07-13", {
compression_savings_spend: 0.1,
prompt_caching_savings_spend: 0.01,
compression_saved_tokens: 100000,
}),
]);
expect(getByText("$0.1560")).toBeInTheDocument();
expect(getByText("$0.1400")).toBeInTheDocument();
expect(getByText("$0.0160")).toBeInTheDocument();
expect(getByText("140,000 tokens compressed")).toBeInTheDocument();
});
it("builds a per-day time series and per-driver donut from the daily rows", () => {
const { getByTestId } = renderWith([
day("2026-07-12", { compression_savings_spend: 0.04, prompt_caching_savings_spend: 0.006 }),
day("2026-07-13", { compression_savings_spend: 0.1, prompt_caching_savings_spend: 0.01 }),
]);
const series = JSON.parse(getByTestId("area-chart").getAttribute("data-series") ?? "[]");
expect(series).toHaveLength(2);
expect(series[0]).toMatchObject({ Compression: 0.04, "Prompt caching": 0.006 });
expect(series[1]).toMatchObject({ Compression: 0.1, "Prompt caching": 0.01 });
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([
{ driver: "Compression", usd: expect.closeTo(0.14, 5) },
{ driver: "Prompt caching", usd: expect.closeTo(0.016, 5) },
]);
});
it("omits a driver slice when that driver has no savings", () => {
const { getByTestId } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })]);
const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]");
expect(slices).toEqual([{ driver: "Compression", usd: expect.closeTo(0.04, 5) }]);
});
});

View file

@ -0,0 +1,184 @@
"use client";
import React, { useMemo, useState } from "react";
import { Collapse } from "antd";
import { AreaChart, DonutChart } from "@/components/shared/charts";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { userDailyActivityCall } from "@/components/networking";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles } from "@/utils/roles";
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
interface UsageTabProps {
accessToken: string | null;
userId: string | null;
userRole: string;
}
type DateRange = { from?: Date; to?: Date };
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const usd = (value: number): string => {
const decimals = value > 0 && value < 1 ? 4 : 2;
return `$${formatNumberWithCommas(value, decimals)}`;
};
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?? 0;
const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0;
const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0;
const MethodologyNote = () => (
<Collapse
ghost
items={[
{
key: "methodology",
label: <span className="text-sm font-medium">How savings are calculated</span>,
children: (
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Savings are computed for each request when it is logged, using the provider&apos;s reported usage and the
model&apos;s pricing, then summed into a daily rollup. Totals below are read from that rollup over the
selected date range, so the numbers never require a scan of raw request logs.
</p>
<p>
Compression savings are the tokens Headroom removed before the call, priced at the model&apos;s input
rate: <code>compression_saved_tokens * input_cost_per_token</code>
</p>
<p>
Prompt caching savings are the tokens the provider served from cache (Anthropic{" "}
<code>cache_read_input_tokens</code>, or OpenAI-style <code>prompt_tokens_details.cached_tokens</code>),
priced at the discount between the normal input rate and the cache-read rate:{" "}
<code>cache_read_input_tokens * max(input_cost_per_token - cache_read_input_token_cost, 0)</code>
</p>
<p>
Total saved is the sum of both drivers. Models without a separate cache-read price in the pricing map
contribute zero caching savings rather than erroring.
</p>
</div>
),
},
]}
/>
);
const SummaryCard = ({ label, value, hint }: { label: string; value: string; hint?: string }) => (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
const UsageTab: React.FC<UsageTabProps> = ({ accessToken, userId, userRole }) => {
const initialFrom = useMemo(() => new Date(new Date().getTime() - THIRTY_DAYS_MS), []);
const initialTo = useMemo(() => new Date(), []);
const [dateValue, setDateValue] = useState<DateRange>({ from: initialFrom, to: initialTo });
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
const isAdmin = all_admin_roles.includes(userRole);
const effectiveUserId = isAdmin ? null : userId;
const { data, loading, isFetchingMore } = usePaginatedDailyActivity({
fetchFn: userDailyActivityCall,
args: [accessToken, startTime, endTime, effectiveUserId],
enabled: !!accessToken && !!startTime && !!endTime,
});
const results = data.results as DailyData[];
const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]);
const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]);
const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]);
const totalSaved = compressionTotal + cachingTotal;
const overTime = useMemo(
() =>
results.map((d) => ({
date: shortDate(d.date),
Compression: compressionOf(d.metrics),
"Prompt caching": cachingOf(d.metrics),
})),
[results],
);
const byDriver = useMemo(
() =>
[
{ driver: "Compression", usd: compressionTotal },
{ driver: "Prompt caching", usd: cachingTotal },
].filter((d) => d.usd > 0),
[compressionTotal, cachingTotal],
);
return (
<div className="w-full space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<MethodologyNote />
<AdvancedDatePicker value={dateValue} onValueChange={(v) => setDateValue(v)} />
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<SummaryCard
label="Total saved"
value={usd(totalSaved)}
hint={loading || isFetchingMore ? "Loading..." : "Compression + prompt caching"}
/>
<SummaryCard
label="Compression savings"
value={usd(compressionTotal)}
hint={`${formatNumberWithCommas(savedTokensTotal)} tokens compressed`}
/>
<SummaryCard label="Prompt caching savings" value={usd(cachingTotal)} hint="Cache read discount" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Savings over time</CardTitle>
</CardHeader>
<CardContent>
<AreaChart
data={overTime}
index="date"
categories={["Compression", "Prompt caching"]}
colors={["emerald", "blue"]}
valueFormatter={usd}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Savings by driver</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
className="h-80"
data={byDriver}
index="driver"
category="usd"
colors={["emerald", "blue"]}
valueFormatter={usd}
showLabel
label={usd(totalSaved)}
/>
</CardContent>
</Card>
</div>
</div>
);
};
export default UsageTab;

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildCompressionGuardrailPayload, compressionGuardrailsOf } from "./helpers";
describe("compressionGuardrailsOf", () => {
it("keeps only headroom-provider guardrails and drops others", () => {
const filtered = compressionGuardrailsOf({
guardrails: [
{ guardrail_id: "1", guardrail_name: "headroom-compression", litellm_params: { guardrail: "headroom" } },
{ guardrail_id: "2", guardrail_name: "pii-masker", litellm_params: { guardrail: "presidio" } },
{ guardrail_id: "3", guardrail_name: "no-params", litellm_params: null },
],
});
expect(filtered.map((g) => g.guardrail_id)).toEqual(["1"]);
});
});
describe("buildCompressionGuardrailPayload", () => {
it("builds a headroom guardrail payload with trimmed fields", () => {
const payload = buildCompressionGuardrailPayload({
name: " headroom-compression ",
apiBase: " https://compress ",
defaultOn: false,
});
expect(payload).toEqual({
guardrail_name: "headroom-compression",
litellm_params: {
guardrail: "headroom",
mode: "pre_call",
api_base: "https://compress",
default_on: false,
},
});
});
});

View file

@ -0,0 +1,39 @@
export interface GuardrailLitellmParams {
guardrail?: string | null;
api_base?: string | null;
default_on?: boolean | null;
}
export interface GuardrailListItem {
guardrail_id: string;
guardrail_name: string | null;
litellm_params?: GuardrailLitellmParams | null;
}
export interface GuardrailListResponse {
guardrails?: GuardrailListItem[];
}
export const COMPRESSION_GUARDRAIL_PROVIDER = "headroom";
export const isCompressionGuardrail = (guardrail: GuardrailListItem): boolean =>
(guardrail.litellm_params?.guardrail ?? "").toLowerCase() === COMPRESSION_GUARDRAIL_PROVIDER;
export const compressionGuardrailsOf = (response: GuardrailListResponse): GuardrailListItem[] =>
(response.guardrails ?? []).filter(isCompressionGuardrail);
export interface CompressionGuardrailInput {
name: string;
apiBase: string;
defaultOn: boolean;
}
export const buildCompressionGuardrailPayload = (input: CompressionGuardrailInput): Record<string, unknown> => ({
guardrail_name: input.name.trim(),
litellm_params: {
guardrail: COMPRESSION_GUARDRAIL_PROVIDER,
mode: "pre_call",
api_base: input.apiBase.trim(),
default_on: input.defaultOn,
},
});

View file

@ -33,7 +33,7 @@ interface GeneralSettingsPageProps {
userID: string | null;
}
interface generalSettingsItem {
export interface generalSettingsItem {
field_name: string;
field_type: string;
field_value: any;
@ -90,7 +90,7 @@ const SettingValueEditor: React.FC<{
return null;
};
const PromptCachingPanel: React.FC<{
export const PromptCachingPanel: React.FC<{
accessToken: string;
settings: generalSettingsItem[];
onChange: (fieldName: string, newValue: any) => void;

View file

@ -77,6 +77,20 @@ describe("ComplexityRouterConfig", () => {
expect(screen.queryByText("Classifier Model")).not.toBeInTheDocument();
});
it("should toggle returning the raw model name", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig {...baseProps} onChange={onChange} />);
await user.click(screen.getByText("Advanced: Response Format"));
await user.click(screen.getByRole("switch"));
expect(onChange).toHaveBeenCalledWith({
...defaultValue,
return_raw_model_name: true,
});
});
it("should reveal classifier model and timeout fields when llm is selected", () => {
const onChange = vi.fn();
renderWithProviders(<ComplexityRouterConfig modelInfo={mockModelInfo} value={defaultValue} onChange={onChange} />);

View file

@ -1,5 +1,5 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Select as AntdSelect, Card, Collapse, Divider, Space, Tooltip, Typography } from "antd";
import { Select as AntdSelect, Card, Collapse, Divider, Space, Switch, Tooltip, Typography } from "antd";
import React from "react";
import { ModelGroup } from "@/components/llm_calls/fetch_models";
import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig";
@ -44,6 +44,7 @@ export interface ComplexityRouterConfigValue {
adaptive_weights?: AdaptiveRouterWeights;
tier_distance_penalty?: number;
adaptive_eligible?: AdaptiveEligible;
return_raw_model_name?: boolean;
}
interface ComplexityRouterConfigProps {
@ -218,6 +219,28 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
),
children: <AdaptiveRoutingConfig value={value} onChange={onChange} />,
},
{
key: "response",
label: (
<Text strong style={{ color: "#374151" }}>
Advanced: Response Format
</Text>
),
children: (
<>
<div className="flex items-center gap-2 mb-2">
<Switch
checked={value.return_raw_model_name ?? false}
onChange={(returnRawModelName) => onChange({ ...value, return_raw_model_name: returnRawModelName })}
/>
<Text strong>Return raw model name</Text>
</div>
<Text type="secondary" style={{ display: "block", fontSize: 12 }}>
Return the resolved underlying model name in responses instead of the autorouter alias.
</Text>
</>
),
},
...(onEscalationKeywordsChange
? [
{

View file

@ -100,6 +100,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
adaptive_weights: adaptiveWeights = DEFAULT_ADAPTIVE_WEIGHTS,
tier_distance_penalty: tierDistancePenalty = DEFAULT_TIER_DISTANCE_PENALTY,
adaptive_eligible: adaptiveEligible = "all",
return_raw_model_name: returnRawModelName = false,
} = complexityRouterConfig;
const missingTiersError = getMissingTiersError(tiers);
@ -148,6 +149,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
adaptiveWeights,
tierDistancePenalty,
adaptiveEligible,
returnRawModelName,
};
const submitValues = {

View file

@ -26,6 +26,7 @@ const baseParams: BuildComplexityRouterConfigParams = {
adaptiveWeights: { quality: 0.3, cost: 0.7 },
tierDistancePenalty: 0.5,
adaptiveEligible: "all",
returnRawModelName: false,
};
describe("buildComplexityRouterConfig", () => {
@ -164,6 +165,16 @@ describe("buildComplexityRouterConfig", () => {
expect(config.adaptive_eligible).toBeUndefined();
});
it("omits return_raw_model_name when disabled", () => {
const config = buildComplexityRouterConfig({ ...baseParams, returnRawModelName: false });
expect(config.return_raw_model_name).toBeUndefined();
});
it("includes return_raw_model_name when enabled", () => {
const config = buildComplexityRouterConfig({ ...baseParams, returnRawModelName: true });
expect(config.return_raw_model_name).toBe(true);
});
it("includes tier_distance_penalty when adaptive is enabled with eligible='all'", () => {
const config = buildComplexityRouterConfig({
...baseParams,

View file

@ -21,6 +21,7 @@ export interface BuildComplexityRouterConfigParams {
adaptiveWeights: AdaptiveRouterWeights;
tierDistancePenalty: number;
adaptiveEligible: AdaptiveEligible;
returnRawModelName: boolean;
}
export interface ComplexityRouterConfigPayload {
@ -37,6 +38,7 @@ export interface ComplexityRouterConfigPayload {
adaptive_weights?: AdaptiveRouterWeights;
tier_distance_penalty?: number;
adaptive_eligible?: AdaptiveEligible;
return_raw_model_name?: boolean;
}
const TIER_KEYS: Array<keyof ComplexityTiers> = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
@ -76,6 +78,7 @@ export const buildComplexityRouterConfig = ({
adaptiveWeights,
tierDistancePenalty,
adaptiveEligible,
returnRawModelName,
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
// Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking
@ -104,5 +107,6 @@ export const buildComplexityRouterConfig = ({
...(adaptiveEligible === "all" && { tier_distance_penalty: tierDistancePenalty }),
adaptive_eligible: adaptiveEligible,
}),
...(returnRawModelName && { return_raw_model_name: true }),
};
};

View file

@ -18,6 +18,7 @@ const storedConfigValue = {
adaptive_weights: { quality: 0.3, cost: 0.7 },
tier_distance_penalty: 0.8,
adaptive_eligible: "all",
return_raw_model_name: true,
};
const storedConfig = JSON.stringify(storedConfigValue);
@ -80,6 +81,15 @@ describe("buildUpdatedComplexityRouterConfig", () => {
expect(updatedConfig).toEqual(expectedAdaptiveDisabledConfig);
});
it("includes return_raw_model_name only when enabled", () => {
const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, {
...classifiedTierValue,
return_raw_model_name: true,
});
expect(updatedConfig.return_raw_model_name).toBe(true);
});
it("updates custom technical keywords when they are edited", () => {
const updatedConfig = buildUpdatedComplexityRouterConfig(storedConfig, classifiedTierValue, ["postgres"]);

View file

@ -38,6 +38,7 @@ const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"adaptive_weights",
"tier_distance_penalty",
"adaptive_eligible",
"return_raw_model_name",
]);
const toRecord = (value: unknown): Record<string, unknown> => {
@ -78,6 +79,7 @@ export const buildUpdatedComplexityRouterConfig = (
}),
adaptive_eligible: adaptiveEligible,
}),
...(value.return_raw_model_name && { return_raw_model_name: true }),
};
};
@ -158,6 +160,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
adaptive_weights: parsedConfig.adaptive_weights,
tier_distance_penalty: parsedConfig.tier_distance_penalty,
adaptive_eligible: parsedConfig.adaptive_eligible || "all",
return_raw_model_name: parsedConfig.return_raw_model_name || false,
});
setCustomTechnicalKeywords(
Array.isArray(parsedConfig.custom_technical_keywords) ? parsedConfig.custom_technical_keywords : [],