mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge origin/litellm_internal_staging into litellm_rust_vertex_auth
Some checks failed
LiteLLM Rust / rustfmt, clippy, test (push) Has been cancelled
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:
commit
fa46ad7767
79 changed files with 1786 additions and 446 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
pub use crate::messages::{messages, MessagesRequest};
|
||||
pub use crate::messages::{MessagesRequest, messages};
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
pub use crate::ocr::{ocr, OcrRequest};
|
||||
pub use crate::ocr::{OcrRequest, ocr};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ¤t_role));
|
||||
}
|
||||
) && !token_file.is_empty()
|
||||
{
|
||||
return Ok(same_role_arns(role, ¤t_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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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=…`).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
56
tests/e2e/e2e_db.py
Normal 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"')
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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) }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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's reported usage and the
|
||||
model'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'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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue