diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index 2386b184e54..492c52233cd 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -10,6 +10,7 @@ UNSUPPORTED: Final = re.compile( r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$" r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$" r"|^tests/e2e/logging/test_otel_v2_langfuse_generation_output_e2e\.py$" + r"|^tests/e2e/logging/test_langsmith_batch_serialization_e2e\.py$" ) HARNESS: Final = re.compile( r"^tests/e2e/[A-Za-z0-9_.-]+\.(py|ini)$" diff --git a/.github/e2e-stack/up.sh b/.github/e2e-stack/up.sh index 928b58e93bb..931b5eb2169 100755 --- a/.github/e2e-stack/up.sh +++ b/.github/e2e-stack/up.sh @@ -24,6 +24,7 @@ DATABASE_USER="${E2E_DATABASE_USER:-litellm}" DATABASE_PASSWORD="${E2E_DATABASE_PASSWORD:-dbpassword9090}" DATABASE_NAME="${E2E_DATABASE_NAME:-litellm}" JAEGER_OTLP_PORT="${E2E_JAEGER_OTLP_PORT:-4318}" +JAEGER_OTLP_TLS_PORT="${E2E_JAEGER_OTLP_TLS_PORT:-4319}" JAEGER_QUERY_PORT="${E2E_JAEGER_QUERY_PORT:-16686}" KEYCLOAK_PORT="${E2E_KEYCLOAK_PORT:-8081}" @@ -122,7 +123,7 @@ SERVER_ENV=( "CONFIG_FILE_PATH=${CONFIG_PATH}" "STORE_MODEL_IN_DB=True" "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf" - "OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:${JAEGER_OTLP_PORT}" + "OTEL_EXPORTER_OTLP_ENDPOINT=https://127.0.0.1:${JAEGER_OTLP_TLS_PORT}" "SSL_CERT_FILE=${CERTS_DIR}/ca-bundle.pem" "PYTHONPATH=${REPO_ROOT}" "JWT_PUBLIC_KEY_URL=http://127.0.0.1:${KEYCLOAK_PORT}/realms/litellm-e2e/protocol/openid-connect/certs" @@ -147,16 +148,12 @@ start_server() { echo $! > "${PIDS_DIR}/${name}.pid" } -start_server backend uv run --no-sync uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT}" -start_server gateway-1 uv run --no-sync uvicorn gateway.main:app --workers 1 --host 0.0.0.0 --port "${GATEWAY_PORT_1}" -start_server gateway-2 uv run --no-sync uvicorn gateway.main:app --workers 1 --host 0.0.0.0 --port "${GATEWAY_PORT_2}" - if [[ "$(uname)" == "Linux" ]]; then NGINX_UPSTREAM_HOST=127.0.0.1 NGINX_DOCKER_ARGS=(--network host) else NGINX_UPSTREAM_HOST=host.docker.internal - NGINX_DOCKER_ARGS=(-p "${LB_PORT}:${LB_PORT}") + NGINX_DOCKER_ARGS=(-p "${LB_PORT}:${LB_PORT}" -p "${JAEGER_OTLP_TLS_PORT}:${JAEGER_OTLP_TLS_PORT}") fi cat > "${STACK_DIR}/nginx.conf" </dev/null 2>&1 || true docker run -d --name e2e-nginx "${NGINX_DOCKER_ARGS[@]}" \ - -v "${STACK_DIR}/nginx.conf:/etc/nginx/nginx.conf:ro" "${NGINX_IMAGE}" >/dev/null + -v "${STACK_DIR}/nginx.conf:/etc/nginx/nginx.conf:ro" \ + -v "${CERTS_DIR}:/certs:ro" "${NGINX_IMAGE}" >/dev/null + +wait_for "Jaeger OTLP TLS listener" \ + "curl -sS --cacert ${CERTS_DIR}/ca.crt https://127.0.0.1:${JAEGER_OTLP_TLS_PORT}/ -o /dev/null -w '%{http_code}' | grep -qE '^[2345]'" + +start_server backend uv run --no-sync uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT}" +start_server gateway-1 uv run --no-sync uvicorn gateway.main:app --workers 1 --host 0.0.0.0 --port "${GATEWAY_PORT_1}" +start_server gateway-2 uv run --no-sync uvicorn gateway.main:app --workers 1 --host 0.0.0.0 --port "${GATEWAY_PORT_2}" wait_for "backend" "curl -fs http://127.0.0.1:${BACKEND_PORT}/health/liveliness >/dev/null" 300 wait_for "gateway-1" "curl -fs http://127.0.0.1:${GATEWAY_PORT_1}/health/liveliness >/dev/null" 300 @@ -206,6 +220,7 @@ LITELLM_MASTER_KEY=${MASTER_KEY} REDIS_HOST=127.0.0.1 REDIS_PORT=${REDIS_PORT} E2E_OTEL_QUERY_URL=http://127.0.0.1:${JAEGER_QUERY_PORT} +E2E_OTEL_EXPORTER_ENDPOINT=https://127.0.0.1:${JAEGER_OTLP_TLS_PORT} E2E_KEYCLOAK_URL=http://127.0.0.1:${KEYCLOAK_PORT} E2E_KEYCLOAK_ADMIN_USER=admin E2E_KEYCLOAK_ADMIN_PASSWORD=e2e-ephemeral-idp-not-a-secret diff --git a/gateway/routes/allowlist.py b/gateway/routes/allowlist.py index fe58e2dd58c..c4a3d3f7473 100644 --- a/gateway/routes/allowlist.py +++ b/gateway/routes/allowlist.py @@ -131,6 +131,7 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset( "/redoc", "/test", "/debug/memory/summary", + "/api/event_logging/batch", } ) diff --git a/helm/litellm/templates/ingress.yaml b/helm/litellm/templates/ingress.yaml index e6717821a57..e9f7ed4ec3f 100644 --- a/helm/litellm/templates/ingress.yaml +++ b/helm/litellm/templates/ingress.yaml @@ -61,7 +61,7 @@ "/v1/fine-tuning" "/fine-tuning" "/v1/responses" "/responses" "/v1/threads" "/threads" "/v1/assistants" "/assistants" "/v1/vector_stores" "/vector_stores" "/v1/indexes" "/v1/models" "/models" "/openai" "/engines" - "/v1/messages" "/messages" "/v1/skills" "/v1/a2a" "/a2a" + "/v1/messages" "/messages" "/v1/skills" "/v1/a2a" "/a2a" "/api/event_logging" "/v1/rerank" "/v2/rerank" "/rerank" "/v1/ocr" "/ocr" "/v1/rag" "/rag" "/v1/video" "/v1/videos" "/video" "/videos" "/v1/search" "/search" "/v1/containers" "/containers" "/v1/evals" "/v1/memory" "/queue/chat" diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 76004c5d978..d9425fc6bd7 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -2727,9 +2727,13 @@ dependencies = [ "litellm-auth-types", "litellm-cache", "litellm-cache-response", + "litellm-cache-testing", + "reqwest 0.12.28", + "rstest", "serde_json", "tokio", "url", + "wiremock", ] [[package]] @@ -2737,6 +2741,7 @@ name = "litellm-cache-disk" version = "0.1.0" dependencies = [ "litellm-cache", + "litellm-cache-testing", "py_literal", "rand 0.8.7", "rstest", @@ -2755,8 +2760,10 @@ dependencies = [ "litellm-auth-gcp", "litellm-auth-types", "litellm-cache", + "litellm-cache-testing", "percent-encoding", "reqwest 0.12.28", + "rstest", "serde_json", "tokio", "wiremock", @@ -2767,8 +2774,8 @@ name = "litellm-cache-memory" version = "0.1.0" dependencies = [ "litellm-cache", + "litellm-cache-testing", "rstest", - "serde_json", "tokio", ] @@ -2776,9 +2783,10 @@ dependencies = [ name = "litellm-cache-qdrant-semantic" version = "0.1.0" dependencies = [ + "futures-executor", "futures-util", "litellm-cache", - "litellm-cache-response", + "litellm-cache-testing", "qdrant-client", "reqwest 0.12.28", "rstest", @@ -2797,9 +2805,11 @@ name = "litellm-cache-redis" version = "0.1.0" dependencies = [ "litellm-cache", + "litellm-cache-testing", "r2d2", "redis", "redis-test", + "rstest", "serde_json", "tokio", ] @@ -2810,10 +2820,10 @@ version = "0.1.0" dependencies = [ "litellm-cache", "litellm-cache-redis", - "litellm-cache-response", - "r2d2", + "litellm-cache-testing", "redis", "redis-test", + "rstest", "serde_json", "sha2 0.10.9", "tokio", @@ -2829,6 +2839,7 @@ dependencies = [ "py_literal", "redis", "redis-test", + "rstest", "serde", "serde_json", "sha2 0.10.9", @@ -2841,22 +2852,35 @@ version = "0.1.0" dependencies = [ "aws-credential-types", "aws-sdk-s3", + "aws-smithy-runtime-api", "aws-smithy-types", "aws-types", + "futures-util", + "http 1.4.2", "litellm-auth-aws", "litellm-cache", + "litellm-cache-testing", + "reqwest 0.12.28", + "rstest", "serde_json", "tokio", "wiremock", ] +[[package]] +name = "litellm-cache-testing" +version = "0.1.0" +dependencies = [ + "litellm-cache", +] + [[package]] name = "litellm-cache-valkey-semantic" version = "0.1.0" dependencies = [ "litellm-cache", "litellm-cache-redis", - "litellm-cache-response", + "litellm-cache-testing", "redis", "redis-test", "rstest", @@ -3072,6 +3096,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "litellm-python-compat" +version = "0.1.0" +dependencies = [ + "criterion", + "hex", + "num-bigint 0.4.8", + "num-traits", + "rstest", + "serde", + "serde-pickle", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "litellm-secrets" version = "0.1.0" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 813d0713128..65813a35214 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -39,6 +39,7 @@ litellm-cache-disk = { path = "crates/cache-disk" } litellm-cache-redis-semantic = { path = "crates/cache-redis-semantic" } litellm-cache-response = { path = "crates/cache-response" } litellm-cache-qdrant-semantic = { path = "crates/cache-qdrant-semantic" } +litellm-cache-testing = { path = "crates/cache-testing" } litellm-token-counter = { path = "crates/token-counter" } litellm-token-counter-fast = { path = "crates/token-counter-fast" } litellm-token-counter-huggingface = { path = "crates/token-counter-huggingface" } diff --git a/litellm-rust/crates/cache-azure-blob/Cargo.toml b/litellm-rust/crates/cache-azure-blob/Cargo.toml index 55abaff1975..baa1b0f5482 100644 --- a/litellm-rust/crates/cache-azure-blob/Cargo.toml +++ b/litellm-rust/crates/cache-azure-blob/Cargo.toml @@ -14,9 +14,14 @@ async-trait = "0.1" azure_core = "1.1.0" azure_storage_blob = "1.1.0" futures-util.workspace = true +reqwest.workspace = true tokio.workspace = true url.workspace = true [dev-dependencies] litellm-cache-response.workspace = true +litellm-cache-testing.workspace = true +rstest.workspace = true serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +wiremock = "0.6.5" diff --git a/litellm-rust/crates/cache-azure-blob/src/cache.rs b/litellm-rust/crates/cache-azure-blob/src/cache.rs index 6a872a0d6e6..489b08d485e 100644 --- a/litellm-rust/crates/cache-azure-blob/src/cache.rs +++ b/litellm-rust/crates/cache-azure-blob/src/cache.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use azure_core::{ credentials::TokenCredential, error::ErrorKind, - http::{ClientOptions, RequestContent}, + http::{ClientOptions, RequestContent, Transport}, }; use azure_storage_blob::{ BlobContainerClient, BlobContainerClientOptions, @@ -11,13 +11,12 @@ use azure_storage_blob::{ }; use futures_util::{TryStreamExt, future::try_join_all}; use litellm_cache::{ - BaseCache, BatchCache, CacheCodec, CacheConnectionResult, CacheConnectionStatus, Error, - ExactCacheContext, FlushCache, + BaseCache, BatchCache, CacheCodec, DisconnectCache, Error, ExactCacheContext, FlushCache, }; use tokio::runtime::Handle; use url::Url; -use crate::credential::AzureBlobCredential; +use crate::{credential::AzureBlobCredential, transport::ReqwestTransport}; pub struct AzureBlobCache { container: BlobContainerClient, @@ -28,9 +27,11 @@ pub struct AzureBlobCache { } impl AzureBlobCache { + /// `http` is the host's pooled client; the SDK sends every request through it. pub async fn connect( account_url: &str, container: &str, + http: reqwest::Client, codec: C, runtime: Handle, ) -> Result { @@ -38,7 +39,10 @@ impl AzureBlobCache { account_url, container, Some(Arc::new(AzureBlobCredential::default())), - ClientOptions::default(), + ClientOptions { + transport: Some(Transport::new(Arc::new(ReqwestTransport(http)))), + ..ClientOptions::default() + }, codec, runtime, ) @@ -152,7 +156,11 @@ impl AzureBlobCache { } fn block_on(&self, future: impl Future) -> T { - self.runtime.block_on(future) + if Handle::try_current().is_ok() { + tokio::task::block_in_place(|| self.runtime.block_on(future)) + } else { + self.runtime.block_on(future) + } } } @@ -217,25 +225,6 @@ impl BaseCache for AzureBlobCache { .await .map(drop) } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - Ok(match self.container.get_properties(None).await { - Ok(_) => CacheConnectionResult { - status: CacheConnectionStatus::Success, - message: "Azure Blob cache connection test successful".into(), - error: None, - }, - Err(error) => CacheConnectionResult { - status: CacheConnectionStatus::Failed, - message: format!("Azure Blob connection failed: {error}"), - error: Some(error.to_string()), - }, - }) - } } impl BatchCache for AzureBlobCache {} @@ -250,5 +239,10 @@ impl FlushCache for AzureBlobCache { } } -#[cfg(test)] -mod tests; +impl DisconnectCache for AzureBlobCache { + /// Python closes its two SDK clients; the Rust clients hold no connection of their own + /// (the pooled transport belongs to the host), so there is nothing to release. + async fn disconnect(&self) -> Result<(), Error> { + Ok(()) + } +} diff --git a/litellm-rust/crates/cache-azure-blob/src/cache/tests.rs b/litellm-rust/crates/cache-azure-blob/src/cache/tests.rs deleted file mode 100644 index f8736ab069b..00000000000 --- a/litellm-rust/crates/cache-azure-blob/src/cache/tests.rs +++ /dev/null @@ -1,746 +0,0 @@ -use std::{ - collections::BTreeMap, - sync::{Arc, Mutex}, - time::Duration, -}; - -use azure_core::http::{ - AsyncRawResponse, Body, ClientOptions, HttpClient, Method, Request, StatusCode, Transport, - headers::{HeaderName, Headers}, -}; -use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheConnectionStatus, Error, ExactCacheContext, FlushCache, -}; -use litellm_cache_response::{ - CacheEntry, CacheKeyField, CacheKeyInput, ResponseCache, ResponseCacheCodec, - ResponseCacheRequest, cache_key, -}; -use serde_json::json; -use tokio::runtime::Runtime; - -use super::AzureBlobCache; - -const ACCOUNT_URL: &str = "https://example.blob.core.windows.net"; -const CONTAINER: &str = "litellm-cache"; -const IF_NONE_MATCH: HeaderName = HeaderName::from_static("if-none-match"); -const ERROR_CODE: HeaderName = HeaderName::from_static("x-ms-error-code"); - -#[derive(Clone, Debug, PartialEq, Eq)] -struct RecordedRequest { - method: Method, - path: String, - query: String, - if_none_match: Option, -} - -#[derive(Default)] -struct FakeState { - container_exists: bool, - blobs: BTreeMap>, - requests: Vec, - failing: bool, - precondition_conflicts: bool, -} - -#[derive(Clone, Default)] -struct FakeBlobService { - state: Arc>, -} - -impl std::fmt::Debug for FakeBlobService { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("FakeBlobService") - } -} - -impl FakeBlobService { - fn with_existing_container() -> Self { - let service = Self::default(); - service.state.lock().unwrap().container_exists = true; - service - } - - fn blob(&self, name: &str) -> Option> { - self.state.lock().unwrap().blobs.get(name).cloned() - } - - fn blob_names(&self) -> Vec { - self.state.lock().unwrap().blobs.keys().cloned().collect() - } - - fn seed_blob(&self, name: &str, bytes: &[u8]) { - self.state - .lock() - .unwrap() - .blobs - .insert(name.to_string(), bytes.to_vec()); - } - - fn set_failing(&self, failing: bool) { - self.state.lock().unwrap().failing = failing; - } - - fn set_precondition_conflicts(&self, enabled: bool) { - self.state.lock().unwrap().precondition_conflicts = enabled; - } - - fn requests(&self) -> Vec { - self.state.lock().unwrap().requests.clone() - } - - fn container_exists(&self) -> bool { - self.state.lock().unwrap().container_exists - } - - fn respond(status: StatusCode, error_code: Option<&str>, body: Vec) -> AsyncRawResponse { - let mut headers = Headers::new(); - if let Some(code) = error_code { - headers.insert(ERROR_CODE, code.to_string()); - } - AsyncRawResponse::from_bytes(status, headers, body) - } - - fn list_body(state: &FakeState) -> Vec { - let mut xml = String::from( - r#""#, - ); - for name in state.blobs.keys() { - xml.push_str(&format!( - "{name}BlockBlob" - )); - } - xml.push_str(""); - xml.into_bytes() - } -} - -#[async_trait::async_trait] -impl HttpClient for FakeBlobService { - async fn execute_request(&self, request: &Request) -> azure_core::Result { - let mut state = self.state.lock().unwrap(); - let path = request.url().path().to_string(); - let query = request.url().query().unwrap_or_default().to_string(); - let if_none_match = request - .headers() - .get_optional_str(&IF_NONE_MATCH) - .map(str::to_owned); - state.requests.push(RecordedRequest { - method: request.method(), - path: path.clone(), - query: query.clone(), - if_none_match: if_none_match.clone(), - }); - if state.failing { - return Ok(Self::respond( - StatusCode::Forbidden, - Some("AuthorizationFailure"), - Vec::new(), - )); - } - let container_path = format!("/{CONTAINER}"); - let blob_name = path - .strip_prefix(&format!("{container_path}/")) - .map(str::to_owned); - let is_container = path == container_path && query.contains("restype=container"); - let response = match (request.method(), is_container, blob_name) { - (Method::Put, true, None) if state.container_exists => Self::respond( - StatusCode::Conflict, - Some("ContainerAlreadyExists"), - Vec::new(), - ), - (Method::Put, true, None) => { - state.container_exists = true; - Self::respond(StatusCode::Created, None, Vec::new()) - } - (Method::Get, true, None) if query.contains("comp=list") => { - Self::respond(StatusCode::Ok, None, Self::list_body(&state)) - } - (Method::Get, true, None) if state.container_exists => { - Self::respond(StatusCode::Ok, None, Vec::new()) - } - (Method::Get, true, None) => { - Self::respond(StatusCode::NotFound, Some("ContainerNotFound"), Vec::new()) - } - (Method::Put, false, Some(name)) => { - if if_none_match.as_deref() == Some("*") && state.blobs.contains_key(&name) { - if state.precondition_conflicts { - Self::respond( - StatusCode::PreconditionFailed, - Some("ConditionNotMet"), - Vec::new(), - ) - } else { - Self::respond(StatusCode::Conflict, Some("BlobAlreadyExists"), Vec::new()) - } - } else { - let bytes = match request.body() { - Body::Bytes(bytes) => bytes.to_vec(), - Body::SeekableStream(_) => panic!("unexpected streaming upload"), - }; - state.blobs.insert(name, bytes); - Self::respond(StatusCode::Created, None, Vec::new()) - } - } - (Method::Get, false, Some(name)) => match state.blobs.get(&name) { - Some(bytes) => Self::respond(StatusCode::Ok, None, bytes.clone()), - None => Self::respond(StatusCode::NotFound, Some("BlobNotFound"), Vec::new()), - }, - (Method::Delete, false, Some(name)) => match state.blobs.remove(&name) { - Some(_) => Self::respond(StatusCode::Accepted, None, Vec::new()), - None => Self::respond(StatusCode::NotFound, Some("BlobNotFound"), Vec::new()), - }, - (method, _, _) => panic!("unexpected request {method:?} {path}?{query}"), - }; - Ok(response) - } -} - -struct Fixture { - runtime: Runtime, - service: FakeBlobService, - cache: Arc>, -} - -impl Fixture { - fn new(service: FakeBlobService) -> Self { - let runtime = Runtime::new().unwrap(); - let cache = runtime - .block_on(Self::connect(&service, runtime.handle().clone())) - .unwrap(); - Self { - runtime, - service, - cache: Arc::new(cache), - } - } - - async fn connect( - service: &FakeBlobService, - handle: tokio::runtime::Handle, - ) -> Result, Error> { - AzureBlobCache::connect_with_options( - ACCOUNT_URL, - CONTAINER, - None, - ClientOptions { - transport: Some(Transport::new(Arc::new(service.clone()))), - ..ClientOptions::default() - }, - ResponseCacheCodec, - handle, - ) - .await - } - - fn response_cache(&self) -> ResponseCache> { - ResponseCache::new(self.cache.clone()) - } - - fn stored_json(&self, key: &str) -> serde_json::Value { - serde_json::from_slice(&self.service.blob(key).expect("blob should exist")).unwrap() - } -} - -fn request(model: &str) -> ResponseCacheRequest { - ResponseCacheRequest::new(CacheKeyInput { - fields: vec![CacheKeyField { - name: "model".into(), - value: Some(model.into()), - api_parameter: true, - internal_parameter: false, - }], - preset: None, - namespace: None, - include_provider_parameters: false, - }) -} - -fn now() -> Duration { - Duration::from_secs(1_700_000_000) -} - -fn entry(value: serde_json::Value) -> CacheEntry { - CacheEntry { - timestamp: Some(1_700_000_000.5), - response: value, - } -} - -fn no_ttl() -> ExactCacheContext { - ExactCacheContext::default() -} - -fn with_ttl(seconds: u64) -> ExactCacheContext { - ExactCacheContext { - ttl: Some(Duration::from_secs(seconds)), - } -} - -#[test] -fn connect_creates_the_container_once() { - let fixture = Fixture::new(FakeBlobService::default()); - assert!(fixture.service.container_exists()); - assert_eq!( - fixture.service.requests(), - vec![RecordedRequest { - method: Method::Put, - path: format!("/{CONTAINER}"), - query: "restype=container".into(), - if_none_match: None, - }] - ); - assert_eq!(fixture.cache.account_url(), ACCOUNT_URL); - assert_eq!(fixture.cache.container_name(), CONTAINER); -} - -#[test] -fn connect_accepts_an_existing_container() { - let fixture = Fixture::new(FakeBlobService::with_existing_container()); - assert!(fixture.service.container_exists()); - assert_eq!(fixture.service.requests().len(), 1); -} - -#[test] -fn connect_accepts_account_urls_with_trailing_slash() { - let runtime = Runtime::new().unwrap(); - let service = FakeBlobService::default(); - let cache = runtime - .block_on(AzureBlobCache::connect_with_options( - "https://example.blob.core.windows.net/", - CONTAINER, - None, - ClientOptions { - transport: Some(Transport::new(Arc::new(service.clone()))), - ..ClientOptions::default() - }, - ResponseCacheCodec, - runtime.handle().clone(), - )) - .unwrap(); - assert_eq!(service.requests()[0].path, format!("/{CONTAINER}")); - assert_eq!(cache.account_url(), "https://example.blob.core.windows.net"); -} - -#[test] -fn connect_keeps_account_url_query_parameters_on_the_container_path() { - let runtime = Runtime::new().unwrap(); - let service = FakeBlobService::default(); - runtime - .block_on(AzureBlobCache::connect_with_options( - "https://example.blob.core.windows.net/?sv=2024-01-01&sig=abc", - CONTAINER, - None, - ClientOptions { - transport: Some(Transport::new(Arc::new(service.clone()))), - ..ClientOptions::default() - }, - ResponseCacheCodec, - runtime.handle().clone(), - )) - .unwrap(); - let create = &service.requests()[0]; - assert_eq!(create.path, format!("/{CONTAINER}")); - assert!(create.query.contains("sig=abc")); -} - -#[test] -fn connect_surfaces_service_failures() { - let runtime = Runtime::new().unwrap(); - let service = FakeBlobService::default(); - service.set_failing(true); - let result = runtime.block_on(Fixture::connect(&service, runtime.handle().clone())); - assert!(matches!(result, Err(Error::Unavailable))); -} - -#[test] -fn sync_set_and_get_round_trip_python_json_shape() { - let fixture = Fixture::new(FakeBlobService::default()); - let value = entry(json!({"choices": [{"message": {"content": "héllo 🌍"}}]})); - fixture - .cache - .set_cache("key-1", value.clone(), &no_ttl()) - .unwrap(); - - assert_eq!( - fixture.stored_json("key-1"), - json!({ - "timestamp": 1_700_000_000.5, - "response": {"choices": [{"message": {"content": "héllo 🌍"}}]} - }) - ); - assert_eq!( - fixture.cache.get_cache("key-1", &no_ttl()).unwrap(), - Some(value) - ); -} - -#[test] -fn sync_set_does_not_overwrite_an_existing_blob() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture - .cache - .set_cache("key", entry(json!({"v": "first"})), &no_ttl()) - .unwrap(); - fixture - .cache - .set_cache("key", entry(json!({"v": "second"})), &no_ttl()) - .unwrap(); - - assert_eq!( - fixture.stored_json("key")["response"], - json!({"v": "first"}) - ); - let uploads: Vec<_> = fixture - .service - .requests() - .into_iter() - .filter(|request| request.method == Method::Put && request.path.ends_with("/key")) - .collect(); - assert_eq!(uploads.len(), 2); - assert!( - uploads - .iter() - .all(|request| request.if_none_match.as_deref() == Some("*")) - ); -} - -#[test] -fn sync_set_treats_a_precondition_conflict_as_an_existing_blob() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture.service.set_precondition_conflicts(true); - fixture - .cache - .set_cache("key", entry(json!({"v": "first"})), &no_ttl()) - .unwrap(); - fixture - .cache - .set_cache("key", entry(json!({"v": "second"})), &no_ttl()) - .unwrap(); - - assert_eq!( - fixture.stored_json("key")["response"], - json!({"v": "first"}) - ); -} - -#[test] -fn async_set_overwrites_an_existing_blob() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture.runtime.block_on(async { - fixture - .cache - .async_set_cache("key", entry(json!({"v": "first"})), no_ttl()) - .await - .unwrap(); - fixture - .cache - .async_set_cache("key", entry(json!({"v": "second"})), no_ttl()) - .await - .unwrap(); - assert_eq!( - fixture - .cache - .async_get_cache("key", &no_ttl()) - .await - .unwrap(), - Some(entry(json!({"v": "second"}))) - ); - }); - assert_eq!( - fixture.stored_json("key")["response"], - json!({"v": "second"}) - ); - assert!( - fixture - .service - .requests() - .iter() - .filter(|request| request.method == Method::Put && request.path.ends_with("/key")) - .all(|request| request.if_none_match.is_none()) - ); -} - -#[test] -fn missing_blobs_are_misses() { - let fixture = Fixture::new(FakeBlobService::default()); - assert_eq!(fixture.cache.get_cache("absent", &no_ttl()).unwrap(), None); - assert_eq!( - fixture - .runtime - .block_on(fixture.cache.async_get_cache("absent", &no_ttl())) - .unwrap(), - None - ); -} - -#[test] -fn ttl_is_ignored_and_entries_never_expire() { - let fixture = Fixture::new(FakeBlobService::default()); - assert_eq!(fixture.cache.get_ttl(&with_ttl(1)), None); - assert_eq!(fixture.cache.get_ttl(&no_ttl()), None); - - fixture - .cache - .set_cache("key", entry(json!("value")), &with_ttl(1)) - .unwrap(); - std::thread::sleep(Duration::from_millis(1100)); - assert_eq!( - fixture.cache.get_cache("key", &with_ttl(1)).unwrap(), - Some(entry(json!("value"))) - ); - assert!( - fixture - .service - .requests() - .iter() - .all(|request| !request.query.contains("expiry")) - ); -} - -#[test] -fn malformed_blobs_are_invalid_entries_and_response_cache_misses() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture.service.seed_blob("broken-json", b"{not json"); - fixture - .service - .seed_blob("broken-utf8", &[0xff, 0xfe, 0x22]); - fixture - .service - .seed_blob("wrong-shape", br#"{"timestamp": "yesterday"}"#); - - for key in ["broken-json", "broken-utf8", "wrong-shape"] { - assert!(matches!( - fixture.cache.get_cache(key, &no_ttl()), - Err(Error::InvalidEntry) - )); - } - - let response_cache = fixture.response_cache(); - let broken = request("broken"); - fixture - .service - .seed_blob(&cache_key(&broken.key), b"{not json"); - assert_eq!(response_cache.lookup(&broken, now()).unwrap(), None); - assert_eq!( - fixture - .runtime - .block_on(response_cache.async_lookup(&broken, now())) - .unwrap(), - None - ); -} - -#[test] -fn batch_get_preserves_order_and_marks_misses_and_invalid_entries() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture - .cache - .set_cache("a", entry(json!("A")), &no_ttl()) - .unwrap(); - fixture - .cache - .set_cache("c", entry(json!("C")), &no_ttl()) - .unwrap(); - fixture.service.seed_blob("bad", b"nope"); - let keys = ["c", "missing", "a", "bad"].map(String::from); - - let sync = fixture.cache.batch_get_cache(&keys, &no_ttl()).unwrap(); - assert_eq!( - sync, - vec![ - BatchEntry::Hit(entry(json!("C"))), - BatchEntry::Miss, - BatchEntry::Hit(entry(json!("A"))), - BatchEntry::Invalid, - ] - ); - - let asynchronous = fixture - .runtime - .block_on(fixture.cache.async_batch_get_cache(keys.to_vec(), no_ttl())) - .unwrap(); - assert_eq!(asynchronous, sync); - - let response_cache = fixture.response_cache(); - let requests = [request("hit"), request("missing"), request("bad")]; - response_cache - .store(&requests[0], json!("HIT"), now()) - .unwrap(); - fixture - .service - .seed_blob(&cache_key(&requests[2].key), b"nope"); - let hits = response_cache.lookup_batch(&requests, now()).unwrap(); - assert_eq!(hits.values, vec![Some(json!("HIT")), None, None]); - assert_eq!(hits.missing_indices, vec![1, 2]); - let async_hits = fixture - .runtime - .block_on(response_cache.async_lookup_batch(&requests, now())) - .unwrap(); - assert_eq!(async_hits.values, hits.values); -} - -#[test] -fn async_pipeline_writes_every_entry_with_overwrite() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture.service.seed_blob("k2", b"stale"); - fixture - .runtime - .block_on(fixture.cache.async_set_cache_pipeline( - vec![ - ("k1".into(), entry(json!({"n": 1}))), - ("k2".into(), entry(json!({"n": 2}))), - ("k3".into(), entry(json!({"n": 3}))), - ], - with_ttl(30), - )) - .unwrap(); - assert_eq!(fixture.service.blob_names(), ["k1", "k2", "k3"]); - assert_eq!(fixture.stored_json("k2")["response"], json!({"n": 2})); -} - -#[test] -fn flush_deletes_every_blob_in_the_container() { - let fixture = Fixture::new(FakeBlobService::default()); - for key in ["x", "y", "z"] { - fixture - .cache - .set_cache(key, entry(json!(key)), &no_ttl()) - .unwrap(); - } - fixture.cache.flush_cache().unwrap(); - assert!(fixture.service.blob_names().is_empty()); - assert!(fixture.service.container_exists()); - - fixture - .cache - .set_cache("again", entry(json!(1)), &no_ttl()) - .unwrap(); - fixture - .runtime - .block_on(fixture.cache.async_flush_cache()) - .unwrap(); - assert!(fixture.service.blob_names().is_empty()); -} - -#[test] -fn service_failures_map_to_unavailable() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture.service.set_failing(true); - assert!(matches!( - fixture.cache.get_cache("key", &no_ttl()), - Err(Error::Unavailable) - )); - assert!(matches!( - fixture.cache.set_cache("key", entry(json!(1)), &no_ttl()), - Err(Error::Unavailable) - )); - assert!(matches!( - fixture.cache.flush_cache(), - Err(Error::Unavailable) - )); - assert!(matches!( - fixture.runtime.block_on( - fixture - .cache - .async_set_cache_pipeline(vec![("k".into(), entry(json!(1)))], no_ttl()) - ), - Err(Error::Unavailable) - )); -} - -#[test] -fn test_connection_reports_container_reachability() { - let fixture = Fixture::new(FakeBlobService::default()); - let ok = fixture - .runtime - .block_on(fixture.cache.test_connection()) - .unwrap(); - assert_eq!(ok.status, CacheConnectionStatus::Success); - assert!(ok.error.is_none()); - - fixture.service.set_failing(true); - let failed = fixture - .runtime - .block_on(fixture.cache.test_connection()) - .unwrap(); - assert_eq!(failed.status, CacheConnectionStatus::Failed); - assert!(failed.error.is_some()); -} - -#[test] -fn disconnect_is_idempotent_and_keeps_data() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture - .cache - .set_cache("key", entry(json!(1)), &no_ttl()) - .unwrap(); - fixture.runtime.block_on(async { - fixture.cache.disconnect().await.unwrap(); - fixture.cache.disconnect().await.unwrap(); - }); - assert_eq!( - fixture.cache.get_cache("key", &no_ttl()).unwrap(), - Some(entry(json!(1))) - ); -} - -#[test] -fn response_cache_stores_and_reads_through_the_backend() { - let fixture = Fixture::new(FakeBlobService::default()); - let response_cache = fixture.response_cache(); - let mut request = request("gpt"); - request.context = with_ttl(60); - let response = json!({"id": "chatcmpl-1"}); - response_cache - .store(&request, response.clone(), now()) - .unwrap(); - assert_eq!( - fixture.stored_json(&cache_key(&request.key)), - json!({"timestamp": 1_700_000_000.0, "response": {"id": "chatcmpl-1"}}) - ); - assert_eq!( - response_cache - .lookup(&request, now() + Duration::from_secs(3600)) - .unwrap(), - Some(response.clone()) - ); - assert_eq!( - fixture - .runtime - .block_on(response_cache.async_lookup(&request, now() + Duration::from_secs(3600))) - .unwrap(), - Some(response.clone()) - ); - fixture.runtime.block_on(async { - response_cache - .async_store(&request, json!("replaced"), now()) - .await - .unwrap(); - assert_eq!( - response_cache.async_lookup(&request, now()).await.unwrap(), - Some(json!("replaced")) - ); - response_cache.async_flush().await.unwrap(); - assert_eq!( - response_cache.async_lookup(&request, now()).await.unwrap(), - None - ); - }); -} - -#[test] -fn non_object_responses_are_written_serialized_like_python() { - let fixture = Fixture::new(FakeBlobService::default()); - fixture - .cache - .set_cache("s", entry(json!("plain")), &no_ttl()) - .unwrap(); - assert_eq!( - fixture.stored_json("s"), - json!({"timestamp": 1_700_000_000.5, "response": "\"plain\""}) - ); - assert_eq!( - fixture.cache.get_cache("s", &no_ttl()).unwrap(), - Some(entry(json!("plain"))) - ); -} diff --git a/litellm-rust/crates/cache-azure-blob/src/lib.rs b/litellm-rust/crates/cache-azure-blob/src/lib.rs index 5ae752c111d..6bcfe130576 100644 --- a/litellm-rust/crates/cache-azure-blob/src/lib.rs +++ b/litellm-rust/crates/cache-azure-blob/src/lib.rs @@ -1,5 +1,7 @@ mod cache; mod credential; +mod transport; pub use cache::AzureBlobCache; pub use credential::AzureBlobCredential; +pub use transport::ReqwestTransport; diff --git a/litellm-rust/crates/cache-azure-blob/src/tests.rs b/litellm-rust/crates/cache-azure-blob/src/tests.rs deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/litellm-rust/crates/cache-azure-blob/src/transport.rs b/litellm-rust/crates/cache-azure-blob/src/transport.rs new file mode 100644 index 00000000000..ed038b8d69d --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/src/transport.rs @@ -0,0 +1,49 @@ +use azure_core::{ + error::ErrorKind, + http::{ + AsyncRawResponse, Body, HttpClient, Request, + headers::{HeaderName, HeaderValue, Headers}, + }, +}; +use futures_util::TryStreamExt; + +#[derive(Debug)] +pub struct ReqwestTransport(pub reqwest::Client); + +#[async_trait::async_trait] +impl HttpClient for ReqwestTransport { + async fn execute_request(&self, request: &Request) -> azure_core::Result { + let method = reqwest::Method::from_bytes(request.method().as_ref().as_bytes()) + .map_err(|error| azure_core::Error::new(ErrorKind::Other, error))?; + let mut outgoing = self.0.request(method, request.url().as_str()); + for (name, value) in request.headers().iter() { + outgoing = outgoing.header(name.as_str(), value.as_str()); + } + let outgoing = match request.body().clone() { + Body::Bytes(bytes) => outgoing.body(bytes), + Body::SeekableStream(stream) => outgoing.body(reqwest::Body::wrap_stream(stream)), + }; + let response = outgoing.send().await.map_err(|error| { + let kind = if error.is_connect() { + ErrorKind::Connection + } else { + ErrorKind::Io + }; + azure_core::Error::new(kind, error) + })?; + let status = response.status().as_u16().into(); + let mut headers = Headers::new(); + for (name, value) in response.headers() { + if let Ok(value) = value.to_str() { + headers.insert( + HeaderName::from(name.as_str().to_owned()), + HeaderValue::from(value.to_owned()), + ); + } + } + let body = response + .bytes_stream() + .map_err(|error| azure_core::Error::new(ErrorKind::Io, error)); + Ok(AsyncRawResponse::new(status, headers, Box::pin(body))) + } +} diff --git a/litellm-rust/crates/cache-azure-blob/tests/cache.rs b/litellm-rust/crates/cache-azure-blob/tests/cache.rs new file mode 100644 index 00000000000..b9f50b824b3 --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/tests/cache.rs @@ -0,0 +1,494 @@ +mod support; + +use std::{sync::Arc, time::Duration}; + +use azure_core::http::Method; +use litellm_cache::{ + BaseCache, BatchCache, BatchEntry, DisconnectCache, Error, ExactCacheContext, FlushCache, +}; +use litellm_cache_azure_blob::AzureBlobCache; +use litellm_cache_response::{ + CacheEntry, CacheKeyField, CacheKeyInput, ResponseCache, ResponseCacheCodec, + ResponseCacheRequest, cache_key, +}; +use rstest::{fixture, rstest}; +use serde_json::json; +use support::{ACCOUNT_URL, CONTAINER, FakeBlobService, RecordedRequest}; +use tokio::runtime::Runtime; + +type Fixture = support::Fixture; + +#[fixture] +fn fixture() -> Fixture { + Fixture::new(FakeBlobService::default(), ResponseCacheCodec) +} + +fn response_cache(fixture: &Fixture) -> ResponseCache> { + ResponseCache::new(fixture.cache.clone()) +} + +fn request(model: &str) -> ResponseCacheRequest { + ResponseCacheRequest::new(CacheKeyInput { + fields: vec![CacheKeyField { + name: "model".into(), + value: Some(model.into()), + api_parameter: true, + internal_parameter: false, + }], + preset: None, + namespace: None, + include_provider_parameters: false, + }) +} + +fn now() -> Duration { + Duration::from_secs(1_700_000_000) +} + +fn entry(value: serde_json::Value) -> CacheEntry { + CacheEntry { + timestamp: Some(1_700_000_000.5), + response: value, + } +} + +fn no_ttl() -> ExactCacheContext { + ExactCacheContext::default() +} + +fn with_ttl(seconds: u64) -> ExactCacheContext { + ExactCacheContext { + ttl: Some(Duration::from_secs(seconds)), + } +} + +fn connect_to(account_url: &str) -> (FakeBlobService, AzureBlobCache) { + let runtime = Runtime::new().unwrap(); + let service = FakeBlobService::default(); + let cache = runtime + .block_on(support::connect( + &service, + account_url, + ResponseCacheCodec, + runtime.handle().clone(), + )) + .unwrap(); + (service, cache) +} + +#[rstest] +fn connect_creates_the_container_once(fixture: Fixture) { + assert!(fixture.service.container_exists()); + assert_eq!( + fixture.service.requests(), + vec![RecordedRequest { + method: Method::Put, + path: format!("/{CONTAINER}"), + query: "restype=container".into(), + if_none_match: None, + }] + ); + assert_eq!(fixture.cache.account_url(), ACCOUNT_URL); + assert_eq!(fixture.cache.container_name(), CONTAINER); +} + +#[rstest] +fn connect_accepts_an_existing_container() { + let fixture = Fixture::new( + FakeBlobService::with_existing_container(), + ResponseCacheCodec, + ); + assert!(fixture.service.container_exists()); + assert_eq!(fixture.service.requests().len(), 1); +} + +#[rstest] +fn connect_accepts_account_urls_with_trailing_slash() { + let (service, cache) = connect_to("https://example.blob.core.windows.net/"); + assert_eq!(service.requests()[0].path, format!("/{CONTAINER}")); + assert_eq!(cache.account_url(), "https://example.blob.core.windows.net"); +} + +#[rstest] +fn connect_keeps_account_url_query_parameters_on_the_container_path() { + let (service, _) = connect_to("https://example.blob.core.windows.net/?sv=2024-01-01&sig=abc"); + let create = &service.requests()[0]; + assert_eq!(create.path, format!("/{CONTAINER}")); + assert!(create.query.contains("sig=abc")); +} + +#[rstest] +fn connect_surfaces_service_failures() { + let runtime = Runtime::new().unwrap(); + let service = FakeBlobService::default(); + service.set_failing(true); + let result = runtime.block_on(support::connect( + &service, + ACCOUNT_URL, + ResponseCacheCodec, + runtime.handle().clone(), + )); + assert!(matches!(result, Err(Error::Unavailable))); +} + +#[rstest] +fn sync_set_and_get_round_trip_python_json_shape(fixture: Fixture) { + let value = entry(json!({"choices": [{"message": {"content": "héllo 🌍"}}]})); + fixture + .cache + .set_cache("key-1", value.clone(), &no_ttl()) + .unwrap(); + + assert_eq!( + fixture.stored_json("key-1"), + json!({ + "timestamp": 1_700_000_000.5, + "response": {"choices": [{"message": {"content": "héllo 🌍"}}]} + }) + ); + assert_eq!( + fixture.cache.get_cache("key-1", &no_ttl()).unwrap(), + Some(value) + ); +} + +#[rstest] +#[case::blob_already_exists(false)] +#[case::precondition_conflict(true)] +fn sync_set_does_not_overwrite_an_existing_blob(fixture: Fixture, #[case] precondition: bool) { + fixture.service.set_precondition_conflicts(precondition); + fixture + .cache + .set_cache("key", entry(json!({"v": "first"})), &no_ttl()) + .unwrap(); + fixture + .cache + .set_cache("key", entry(json!({"v": "second"})), &no_ttl()) + .unwrap(); + + assert_eq!( + fixture.stored_json("key")["response"], + json!({"v": "first"}) + ); + let uploads: Vec<_> = fixture + .service + .requests() + .into_iter() + .filter(|request| request.method == Method::Put && request.path.ends_with("/key")) + .collect(); + assert_eq!(uploads.len(), 2); + assert!( + uploads + .iter() + .all(|request| request.if_none_match.as_deref() == Some("*")) + ); +} + +#[rstest] +fn async_set_overwrites_an_existing_blob(fixture: Fixture) { + fixture.runtime.block_on(async { + fixture + .cache + .async_set_cache("key", entry(json!({"v": "first"})), no_ttl()) + .await + .unwrap(); + fixture + .cache + .async_set_cache("key", entry(json!({"v": "second"})), no_ttl()) + .await + .unwrap(); + assert_eq!( + fixture + .cache + .async_get_cache("key", &no_ttl()) + .await + .unwrap(), + Some(entry(json!({"v": "second"}))) + ); + }); + assert_eq!( + fixture.stored_json("key")["response"], + json!({"v": "second"}) + ); + assert!( + fixture + .service + .requests() + .iter() + .filter(|request| request.method == Method::Put && request.path.ends_with("/key")) + .all(|request| request.if_none_match.is_none()) + ); +} + +#[rstest] +fn missing_blobs_are_misses(fixture: Fixture) { + assert_eq!(fixture.cache.get_cache("absent", &no_ttl()).unwrap(), None); + assert_eq!( + fixture + .runtime + .block_on(fixture.cache.async_get_cache("absent", &no_ttl())) + .unwrap(), + None + ); +} + +#[rstest] +fn ttl_is_ignored_and_entries_never_expire(fixture: Fixture) { + assert_eq!(fixture.cache.get_ttl(&with_ttl(1)), None); + assert_eq!(fixture.cache.get_ttl(&no_ttl()), None); + + fixture + .cache + .set_cache("key", entry(json!("value")), &with_ttl(1)) + .unwrap(); + std::thread::sleep(Duration::from_millis(1100)); + assert_eq!( + fixture.cache.get_cache("key", &with_ttl(1)).unwrap(), + Some(entry(json!("value"))) + ); + assert!( + fixture + .service + .requests() + .iter() + .all(|request| !request.query.contains("expiry")) + ); +} + +#[rstest] +#[case::broken_json("broken-json", b"{not json".as_slice())] +#[case::broken_utf8("broken-utf8", &[0xff, 0xfe, 0x22])] +#[case::wrong_shape("wrong-shape", br#"{"timestamp": "yesterday"}"#.as_slice())] +fn malformed_blobs_are_invalid_entries(fixture: Fixture, #[case] key: &str, #[case] bytes: &[u8]) { + fixture.service.seed_blob(key, bytes); + assert!(matches!( + fixture.cache.get_cache(key, &no_ttl()), + Err(Error::InvalidEntry) + )); +} + +#[rstest] +fn malformed_blobs_are_response_cache_misses(fixture: Fixture) { + let response_cache = response_cache(&fixture); + let broken = request("broken"); + fixture + .service + .seed_blob(&cache_key(&broken.key), b"{not json"); + assert_eq!(response_cache.lookup(&broken, now()).unwrap(), None); + assert_eq!( + fixture + .runtime + .block_on(response_cache.async_lookup(&broken, now())) + .unwrap(), + None + ); +} + +#[rstest] +fn batch_get_preserves_order_and_marks_misses_and_invalid_entries(fixture: Fixture) { + fixture + .cache + .set_cache("a", entry(json!("A")), &no_ttl()) + .unwrap(); + fixture + .cache + .set_cache("c", entry(json!("C")), &no_ttl()) + .unwrap(); + fixture.service.seed_blob("bad", b"nope"); + let keys = ["c", "missing", "a", "bad"].map(String::from); + + let sync = fixture.cache.batch_get_cache(&keys, &no_ttl()).unwrap(); + assert_eq!( + sync, + vec![ + BatchEntry::Hit(entry(json!("C"))), + BatchEntry::Miss, + BatchEntry::Hit(entry(json!("A"))), + BatchEntry::Invalid, + ] + ); + + let asynchronous = fixture + .runtime + .block_on(fixture.cache.async_batch_get_cache(keys.to_vec(), no_ttl())) + .unwrap(); + assert_eq!(asynchronous, sync); + + let response_cache = response_cache(&fixture); + let requests = [request("hit"), request("missing"), request("bad")]; + response_cache + .store(&requests[0], json!("HIT"), now()) + .unwrap(); + fixture + .service + .seed_blob(&cache_key(&requests[2].key), b"nope"); + let hits = response_cache.lookup_batch(&requests, now()).unwrap(); + assert_eq!(hits.values, vec![Some(json!("HIT")), None, None]); + assert_eq!(hits.missing_indices, vec![1, 2]); + let async_hits = fixture + .runtime + .block_on(response_cache.async_lookup_batch(&requests, now())) + .unwrap(); + assert_eq!(async_hits.values, hits.values); +} + +#[rstest] +fn async_pipeline_writes_every_entry_with_overwrite(fixture: Fixture) { + fixture.service.seed_blob("k2", b"stale"); + fixture + .runtime + .block_on(fixture.cache.async_set_cache_pipeline( + vec![ + ("k1".into(), entry(json!({"n": 1}))), + ("k2".into(), entry(json!({"n": 2}))), + ("k3".into(), entry(json!({"n": 3}))), + ], + with_ttl(30), + )) + .unwrap(); + assert_eq!(fixture.service.blob_names(), ["k1", "k2", "k3"]); + assert_eq!(fixture.stored_json("k2")["response"], json!({"n": 2})); +} + +#[rstest] +fn flush_deletes_every_blob_in_the_container(fixture: Fixture) { + for key in ["x", "y", "z"] { + fixture + .cache + .set_cache(key, entry(json!(key)), &no_ttl()) + .unwrap(); + } + fixture.cache.flush_cache().unwrap(); + assert!(fixture.service.blob_names().is_empty()); + assert!(fixture.service.container_exists()); + + fixture + .cache + .set_cache("again", entry(json!(1)), &no_ttl()) + .unwrap(); + fixture + .runtime + .block_on(fixture.cache.async_flush_cache()) + .unwrap(); + assert!(fixture.service.blob_names().is_empty()); +} + +#[rstest] +fn service_failures_map_to_unavailable(fixture: Fixture) { + fixture.service.set_failing(true); + assert!(matches!( + fixture.cache.get_cache("key", &no_ttl()), + Err(Error::Unavailable) + )); + assert!(matches!( + fixture.cache.set_cache("key", entry(json!(1)), &no_ttl()), + Err(Error::Unavailable) + )); + assert!(matches!( + fixture.cache.flush_cache(), + Err(Error::Unavailable) + )); + assert!(matches!( + fixture.runtime.block_on( + fixture + .cache + .async_set_cache_pipeline(vec![("k".into(), entry(json!(1)))], no_ttl()) + ), + Err(Error::Unavailable) + )); +} + +#[rstest] +fn disconnect_is_idempotent_and_keeps_data(fixture: Fixture) { + fixture + .cache + .set_cache("key", entry(json!(1)), &no_ttl()) + .unwrap(); + fixture.runtime.block_on(async { + fixture.cache.disconnect().await.unwrap(); + fixture.cache.disconnect().await.unwrap(); + }); + assert_eq!( + fixture.cache.get_cache("key", &no_ttl()).unwrap(), + Some(entry(json!(1))) + ); +} + +#[rstest] +fn response_cache_stores_and_reads_through_the_backend(fixture: Fixture) { + let response_cache = response_cache(&fixture); + let mut request = request("gpt"); + request.context = with_ttl(60); + let response = json!({"id": "chatcmpl-1"}); + response_cache + .store(&request, response.clone(), now()) + .unwrap(); + assert_eq!( + fixture.stored_json(&cache_key(&request.key)), + json!({"timestamp": 1_700_000_000.0, "response": {"id": "chatcmpl-1"}}) + ); + assert_eq!( + response_cache + .lookup(&request, now() + Duration::from_secs(3600)) + .unwrap(), + Some(response.clone()) + ); + assert_eq!( + fixture + .runtime + .block_on(response_cache.async_lookup(&request, now() + Duration::from_secs(3600))) + .unwrap(), + Some(response.clone()) + ); + fixture.runtime.block_on(async { + response_cache + .async_store(&request, json!("replaced"), now()) + .await + .unwrap(); + assert_eq!( + response_cache.async_lookup(&request, now()).await.unwrap(), + Some(json!("replaced")) + ); + response_cache.async_flush().await.unwrap(); + assert_eq!( + response_cache.async_lookup(&request, now()).await.unwrap(), + None + ); + }); +} + +#[rstest] +fn non_object_responses_are_written_serialized_like_python(fixture: Fixture) { + fixture + .cache + .set_cache("s", entry(json!("plain")), &no_ttl()) + .unwrap(); + assert_eq!( + fixture.stored_json("s"), + json!({"timestamp": 1_700_000_000.5, "response": "\"plain\""}) + ); + assert_eq!( + fixture.cache.get_cache("s", &no_ttl()).unwrap(), + Some(entry(json!("plain"))) + ); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn sync_methods_block_inside_a_multi_thread_runtime() { + let service = FakeBlobService::default(); + let cache = support::connect( + &service, + ACCOUNT_URL, + ResponseCacheCodec, + tokio::runtime::Handle::current(), + ) + .await + .map(Arc::new) + .unwrap(); + cache.set_cache("key", entry(json!(1)), &no_ttl()).unwrap(); + assert_eq!( + cache.get_cache("key", &no_ttl()).unwrap(), + Some(entry(json!(1))) + ); +} diff --git a/litellm-rust/crates/cache-azure-blob/tests/contract.rs b/litellm-rust/crates/cache-azure-blob/tests/contract.rs new file mode 100644 index 00000000000..585ca26bbd7 --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/tests/contract.rs @@ -0,0 +1,81 @@ +mod support; + +use litellm_cache::{ExactCacheContext, JsonCodec}; +use litellm_cache_azure_blob::AzureBlobCache; +use litellm_cache_testing as contract; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use support::{ACCOUNT_URL, FakeBlobService}; +use tokio::runtime::Handle; + +#[fixture] +async fn azure() -> AzureBlobCache> { + support::connect( + &FakeBlobService::default(), + ACCOUNT_URL, + JsonCodec::new(), + Handle::current(), + ) + .await + .unwrap() +} + +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext::default() +} + +const PREFIX: &str = "contract:"; + +// `overwrite_replaces` does not apply: sync `set_cache` never overwrites a blob, as in Python. + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn hit_and_miss( + #[future(awt)] azure: AzureBlobCache>, + context: ExactCacheContext, +) { + contract::hit_and_miss(&azure, context, PREFIX, json!({"answer": 42})).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn sync_async_equivalence( + #[future(awt)] azure: AzureBlobCache>, + context: ExactCacheContext, +) { + contract::sync_async_equivalence(&azure, context, PREFIX, json!("first"), json!([2])).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn pipeline_writes_every_entry( + #[future(awt)] azure: AzureBlobCache>, + context: ExactCacheContext, +) { + contract::pipeline_writes_every_entry( + &azure, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn batch_preserves_order( + #[future(awt)] azure: AzureBlobCache>, + context: ExactCacheContext, +) { + contract::batch_preserves_order(&azure, context, PREFIX, json!("first"), json!(2)).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn flush_clears( + #[future(awt)] azure: AzureBlobCache>, + context: ExactCacheContext, +) { + contract::flush_clears(&azure, context, PREFIX, json!("value")).await; +} diff --git a/litellm-rust/crates/cache-azure-blob/tests/support/mod.rs b/litellm-rust/crates/cache-azure-blob/tests/support/mod.rs new file mode 100644 index 00000000000..f908151c22e --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/tests/support/mod.rs @@ -0,0 +1,239 @@ +#![allow(dead_code)] + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use azure_core::http::{ + AsyncRawResponse, Body, ClientOptions, HttpClient, Method, Request, StatusCode, Transport, + headers::{HeaderName, Headers}, +}; +use litellm_cache::{CacheCodec, Error}; +use litellm_cache_azure_blob::AzureBlobCache; +use tokio::runtime::{Handle, Runtime}; + +pub const ACCOUNT_URL: &str = "https://example.blob.core.windows.net"; +pub const CONTAINER: &str = "litellm-cache"; +const IF_NONE_MATCH: HeaderName = HeaderName::from_static("if-none-match"); +const ERROR_CODE: HeaderName = HeaderName::from_static("x-ms-error-code"); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecordedRequest { + pub method: Method, + pub path: String, + pub query: String, + pub if_none_match: Option, +} + +#[derive(Default)] +struct FakeState { + container_exists: bool, + blobs: BTreeMap>, + requests: Vec, + failing: bool, + precondition_conflicts: bool, +} + +#[derive(Clone, Default)] +pub struct FakeBlobService { + state: Arc>, +} + +impl std::fmt::Debug for FakeBlobService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("FakeBlobService") + } +} + +impl FakeBlobService { + pub fn with_existing_container() -> Self { + let service = Self::default(); + service.state.lock().unwrap().container_exists = true; + service + } + + pub fn blob(&self, name: &str) -> Option> { + self.state.lock().unwrap().blobs.get(name).cloned() + } + + pub fn blob_names(&self) -> Vec { + self.state.lock().unwrap().blobs.keys().cloned().collect() + } + + pub fn seed_blob(&self, name: &str, bytes: &[u8]) { + self.state + .lock() + .unwrap() + .blobs + .insert(name.to_string(), bytes.to_vec()); + } + + pub fn set_failing(&self, failing: bool) { + self.state.lock().unwrap().failing = failing; + } + + pub fn set_precondition_conflicts(&self, enabled: bool) { + self.state.lock().unwrap().precondition_conflicts = enabled; + } + + pub fn requests(&self) -> Vec { + self.state.lock().unwrap().requests.clone() + } + + pub fn container_exists(&self) -> bool { + self.state.lock().unwrap().container_exists + } + + fn respond(status: StatusCode, error_code: Option<&str>, body: Vec) -> AsyncRawResponse { + let mut headers = Headers::new(); + if let Some(code) = error_code { + headers.insert(ERROR_CODE, code.to_string()); + } + AsyncRawResponse::from_bytes(status, headers, body) + } + + fn list_body(state: &FakeState) -> Vec { + let mut xml = String::from( + r#""#, + ); + for name in state.blobs.keys() { + xml.push_str(&format!( + "{name}BlockBlob" + )); + } + xml.push_str(""); + xml.into_bytes() + } +} + +#[async_trait::async_trait] +impl HttpClient for FakeBlobService { + async fn execute_request(&self, request: &Request) -> azure_core::Result { + let mut state = self.state.lock().unwrap(); + let path = request.url().path().to_string(); + let query = request.url().query().unwrap_or_default().to_string(); + let if_none_match = request + .headers() + .get_optional_str(&IF_NONE_MATCH) + .map(str::to_owned); + state.requests.push(RecordedRequest { + method: request.method(), + path: path.clone(), + query: query.clone(), + if_none_match: if_none_match.clone(), + }); + if state.failing { + return Ok(Self::respond( + StatusCode::Forbidden, + Some("AuthorizationFailure"), + Vec::new(), + )); + } + let container_path = format!("/{CONTAINER}"); + let blob_name = path + .strip_prefix(&format!("{container_path}/")) + .map(str::to_owned); + let is_container = path == container_path && query.contains("restype=container"); + let response = match (request.method(), is_container, blob_name) { + (Method::Put, true, None) if state.container_exists => Self::respond( + StatusCode::Conflict, + Some("ContainerAlreadyExists"), + Vec::new(), + ), + (Method::Put, true, None) => { + state.container_exists = true; + Self::respond(StatusCode::Created, None, Vec::new()) + } + (Method::Get, true, None) if query.contains("comp=list") => { + Self::respond(StatusCode::Ok, None, Self::list_body(&state)) + } + (Method::Get, true, None) if state.container_exists => { + Self::respond(StatusCode::Ok, None, Vec::new()) + } + (Method::Get, true, None) => { + Self::respond(StatusCode::NotFound, Some("ContainerNotFound"), Vec::new()) + } + (Method::Put, false, Some(name)) => { + if if_none_match.as_deref() == Some("*") && state.blobs.contains_key(&name) { + if state.precondition_conflicts { + Self::respond( + StatusCode::PreconditionFailed, + Some("ConditionNotMet"), + Vec::new(), + ) + } else { + Self::respond(StatusCode::Conflict, Some("BlobAlreadyExists"), Vec::new()) + } + } else { + let bytes = match request.body() { + Body::Bytes(bytes) => bytes.to_vec(), + Body::SeekableStream(_) => panic!("unexpected streaming upload"), + }; + state.blobs.insert(name, bytes); + Self::respond(StatusCode::Created, None, Vec::new()) + } + } + (Method::Get, false, Some(name)) => match state.blobs.get(&name) { + Some(bytes) => Self::respond(StatusCode::Ok, None, bytes.clone()), + None => Self::respond(StatusCode::NotFound, Some("BlobNotFound"), Vec::new()), + }, + (Method::Delete, false, Some(name)) => match state.blobs.remove(&name) { + Some(_) => Self::respond(StatusCode::Accepted, None, Vec::new()), + None => Self::respond(StatusCode::NotFound, Some("BlobNotFound"), Vec::new()), + }, + (method, _, _) => panic!("unexpected request {method:?} {path}?{query}"), + }; + Ok(response) + } +} + +pub async fn connect( + service: &FakeBlobService, + account_url: &str, + codec: C, + handle: Handle, +) -> Result, Error> { + AzureBlobCache::connect_with_options( + account_url, + CONTAINER, + None, + ClientOptions { + transport: Some(Transport::new(Arc::new(service.clone()))), + ..ClientOptions::default() + }, + codec, + handle, + ) + .await +} + +/// A cache on its own fake service and runtime, so sync methods run outside any runtime. +pub struct Fixture { + pub runtime: Runtime, + pub service: FakeBlobService, + pub cache: Arc>, +} + +impl Fixture { + pub fn new(service: FakeBlobService, codec: C) -> Self { + let runtime = Runtime::new().unwrap(); + let cache = runtime + .block_on(connect( + &service, + ACCOUNT_URL, + codec, + runtime.handle().clone(), + )) + .unwrap(); + Self { + runtime, + service, + cache: Arc::new(cache), + } + } + + pub fn stored_json(&self, key: &str) -> serde_json::Value { + serde_json::from_slice(&self.service.blob(key).expect("blob should exist")).unwrap() + } +} diff --git a/litellm-rust/crates/cache-azure-blob/tests/transport.rs b/litellm-rust/crates/cache-azure-blob/tests/transport.rs new file mode 100644 index 00000000000..cd1e10aa3d8 --- /dev/null +++ b/litellm-rust/crates/cache-azure-blob/tests/transport.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use azure_core::http::{ClientOptions, Transport}; +use litellm_cache::{BaseCache, ExactCacheContext, JsonCodec}; +use litellm_cache_azure_blob::{AzureBlobCache, ReqwestTransport}; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use tokio::runtime::Handle; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, header, method, path, query_param}, +}; + +#[fixture] +async fn server() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/litellm-cache")) + .and(query_param("restype", "container")) + .respond_with(ResponseTemplate::new(201)) + .expect(1) + .mount(&server) + .await; + server +} + +async fn connect(server: &MockServer) -> AzureBlobCache> { + AzureBlobCache::connect_with_options( + &server.uri(), + "litellm-cache", + None, + ClientOptions { + transport: Some(Transport::new(Arc::new(ReqwestTransport( + reqwest::Client::new(), + )))), + ..ClientOptions::default() + }, + JsonCodec::new(), + Handle::current(), + ) + .await + .unwrap() +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn uploads_go_through_the_host_client(#[future(awt)] server: MockServer) { + Mock::given(method("PUT")) + .and(path("/litellm-cache/key")) + .and(header("if-none-match", "*")) + .and(body_json(json!({"answer": 1}))) + .respond_with(ResponseTemplate::new(201)) + .expect(1) + .mount(&server) + .await; + connect(&server) + .await + .set_cache("key", json!({"answer": 1}), &ExactCacheContext::default()) + .unwrap(); +} + +#[rstest] +#[case::hit( + ResponseTemplate::new(200).set_body_json(json!({"answer": 2})), + Some(json!({"answer": 2})) +)] +#[case::blob_not_found( + ResponseTemplate::new(404).insert_header("x-ms-error-code", "BlobNotFound"), + None +)] +#[tokio::test(flavor = "multi_thread")] +async fn downloads_map_the_host_client_response( + #[future(awt)] server: MockServer, + #[case] response: ResponseTemplate, + #[case] expected: Option, +) { + Mock::given(method("GET")) + .and(path("/litellm-cache/key")) + .respond_with(response) + .mount(&server) + .await; + assert_eq!( + connect(&server) + .await + .async_get_cache("key", &ExactCacheContext::default()) + .await + .unwrap(), + expected + ); +} diff --git a/litellm-rust/crates/cache-disk/Cargo.toml b/litellm-rust/crates/cache-disk/Cargo.toml index b96994b3b55..5cb75f7f129 100644 --- a/litellm-rust/crates/cache-disk/Cargo.toml +++ b/litellm-rust/crates/cache-disk/Cargo.toml @@ -15,5 +15,6 @@ serde_json.workspace = true tokio.workspace = true [dev-dependencies] +litellm-cache-testing.workspace = true rstest.workspace = true tempfile = "3.27.0" diff --git a/litellm-rust/crates/cache-disk/src/cache.rs b/litellm-rust/crates/cache-disk/src/cache.rs index 8e1223309b4..b8a2c6ed1e4 100644 --- a/litellm-rust/crates/cache-disk/src/cache.rs +++ b/litellm-rust/crates/cache-disk/src/cache.rs @@ -5,8 +5,8 @@ use std::{ }; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheCodec, CacheConnectionResult, CacheConnectionStatus, - CounterCache, DeleteCache, Error, ExactCacheContext, FlushCache, + BaseCache, BatchCache, BatchEntry, CacheCodec, CounterCache, DeleteCache, DisconnectCache, + Error, ExactCacheContext, FlushCache, }; use crate::{DiskStore, DiskcacheSqliteStore, PythonDiskCacheAdapter, StoredValue, ValueAdapter}; @@ -150,29 +150,6 @@ impl BaseCache for DiskCache Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - let result = Self::run_blocking(Arc::clone(&self.store), |store| { - store.probe().map(|_| CacheConnectionResult { - status: CacheConnectionStatus::Success, - message: "Disk cache connection test successful".into(), - error: None, - }) - }) - .await; - Ok(match result { - Ok(result) => result, - Err(error) => CacheConnectionResult { - status: CacheConnectionStatus::Failed, - message: format!("Disk cache connection failed: {error}"), - error: Some(error.to_string()), - }, - }) - } } impl BatchCache for DiskCache { @@ -241,9 +218,13 @@ impl FlushCache for DiskCache, D: DiskStore, A: ValueAdapter> CounterCache - for DiskCache -{ +impl DisconnectCache for DiskCache { + async fn disconnect(&self) -> Result<(), Error> { + Ok(()) + } +} + +impl CounterCache for DiskCache { fn increment_cache( &self, key: &str, @@ -264,6 +245,7 @@ impl, D: DiskStore, A: ValueAdapter> CounterCache key: &str, amount: f64, context: ExactCacheContext, + _refresh_ttl: bool, ) -> Result { let key = key.to_string(); let adapter = Arc::clone(&self.adapter); diff --git a/litellm-rust/crates/cache-disk/src/sqlite.rs b/litellm-rust/crates/cache-disk/src/sqlite.rs index 9a36f8af6ad..24c2be2e1c5 100644 --- a/litellm-rust/crates/cache-disk/src/sqlite.rs +++ b/litellm-rust/crates/cache-disk/src/sqlite.rs @@ -544,18 +544,6 @@ impl DiskStore for DiskcacheSqliteStore { } } } - - fn probe(&self) -> Result<(), Error> { - let connection = self.connection.lock().map_err(|_| Error::Unavailable)?; - connection - .query_row( - "SELECT value FROM Settings WHERE key = 'count'", - [], - |row| row.get::<_, i64>(0), - ) - .map(|_| ()) - .map_err(|_| Error::Unavailable) - } } fn default_settings() -> HashMap { diff --git a/litellm-rust/crates/cache-disk/src/store.rs b/litellm-rust/crates/cache-disk/src/store.rs index ed167317cf0..b5c12003cee 100644 --- a/litellm-rust/crates/cache-disk/src/store.rs +++ b/litellm-rust/crates/cache-disk/src/store.rs @@ -29,5 +29,4 @@ pub trait DiskStore: Send + Sync + 'static { now: f64, apply: &mut dyn FnMut(Option) -> Result<(StoredValue, Option), Error>, ) -> Result<(), Error>; - fn probe(&self) -> Result<(), Error>; } diff --git a/litellm-rust/crates/cache-disk/tests/cache.rs b/litellm-rust/crates/cache-disk/tests/cache.rs index dd1f2b1f04e..8c817a8a829 100644 --- a/litellm-rust/crates/cache-disk/tests/cache.rs +++ b/litellm-rust/crates/cache-disk/tests/cache.rs @@ -7,8 +7,8 @@ use std::{ }; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheCodec, CounterCache, DeleteCache, ExactCacheContext, - FlushCache, JsonCodec, + BaseCache, BatchCache, BatchEntry, CacheCodec, CounterCache, DeleteCache, DisconnectCache, + ExactCacheContext, FlushCache, JsonCodec, }; use litellm_cache_disk::{DiskCache, DiskStore, DiskcacheSqliteStore, StoredValue, ValueAdapter}; use rstest::{fixture, rstest}; @@ -395,7 +395,7 @@ fn delete_flush_and_spilled_file_replacement_clean_up_storage(sandbox: Sandbox) #[rstest] #[tokio::test] -async fn async_operations_connection_and_delete_match_sync_operations(sandbox: Sandbox) { +async fn async_operations_disconnect_and_delete_match_sync_operations(sandbox: Sandbox) { let cache = sandbox.cache::(); let context = ExactCacheContext { ttl: Some(Duration::from_secs(60)), @@ -424,8 +424,101 @@ async fn async_operations_connection_and_delete_match_sync_operations(sandbox: S ); cache.async_delete_cache("a").await.unwrap(); cache.async_flush_cache().await.unwrap(); + cache.disconnect().await.unwrap(); +} + +#[derive(Clone, Copy, Debug)] +enum Increment { + Sync, + Async { refresh_ttl: bool }, +} + +impl Increment { + async fn apply( + self, + cache: &DiskCache>, + key: &str, + amount: f64, + context: ExactCacheContext, + ) -> f64 { + match self { + Self::Sync => cache.increment_cache(key, amount, context).unwrap(), + Self::Async { refresh_ttl } => cache + .async_increment(key, amount, context, refresh_ttl) + .await + .unwrap(), + } + } +} + +#[rstest] +#[case::sync_missing(Increment::Sync, None, 3.0, 3.0)] +#[case::sync_existing_int(Increment::Sync, Some(json!(7)), 5.0, 12.0)] +#[case::sync_non_int(Increment::Sync, Some(json!("not-a-number")), 4.0, 4.0)] +#[case::async_missing(Increment::Async { refresh_ttl: false }, None, 2.0, 2.0)] +#[case::async_existing_int(Increment::Async { refresh_ttl: false }, Some(json!(10)), 5.0, 15.0)] +#[case::async_non_int(Increment::Async { refresh_ttl: false }, Some(json!("corrupt")), 9.0, 9.0)] +#[case::async_refresh_ttl_is_ignored(Increment::Async { refresh_ttl: true }, Some(json!(1)), 1.0, 2.0)] +#[tokio::test] +async fn increments_read_back_through_get_cache( + sandbox: Sandbox, + #[case] increment: Increment, + #[case] initial: Option, + #[case] amount: f64, + #[case] expected: f64, +) { + let cache = sandbox.cache::(); + let context = ExactCacheContext::default(); + if let Some(initial) = initial { + cache + .async_set_cache("counter", initial, context.clone()) + .await + .unwrap(); + } assert_eq!( - cache.test_connection().await.unwrap().status, - litellm_cache::CacheConnectionStatus::Success + increment + .apply(&cache, "counter", amount, context.clone()) + .await, + expected + ); + assert_eq!( + cache.get_cache("counter", &context).unwrap(), + Some(json!(expected as i64)) ); } + +#[rstest] +#[case::without_refresh(false)] +#[case::with_refresh(true)] +#[tokio::test] +async fn async_increment_rewrites_ttl_on_every_write(sandbox: Sandbox, #[case] refresh_ttl: bool) { + let cache = sandbox.cache::(); + let expiry = || { + sandbox + .db() + .query_row( + "SELECT expire_time IS NOT NULL FROM Cache WHERE key = 'counter'", + [], + |row| row.get::<_, bool>(0), + ) + .unwrap() + }; + let ttl = ExactCacheContext { + ttl: Some(Duration::from_secs(60)), + }; + cache + .async_increment("counter", 1.0, ttl.clone(), refresh_ttl) + .await + .unwrap(); + assert!(expiry()); + cache + .async_increment("counter", 1.0, ExactCacheContext::default(), refresh_ttl) + .await + .unwrap(); + assert!(!expiry()); + cache + .async_increment("counter", 1.0, ttl, refresh_ttl) + .await + .unwrap(); + assert!(expiry()); +} diff --git a/litellm-rust/crates/cache-disk/tests/contract.rs b/litellm-rust/crates/cache-disk/tests/contract.rs new file mode 100644 index 00000000000..257252cb90c --- /dev/null +++ b/litellm-rust/crates/cache-disk/tests/contract.rs @@ -0,0 +1,82 @@ +use litellm_cache::{ExactCacheContext, JsonCodec}; +use litellm_cache_disk::DiskCache; +use litellm_cache_testing as contract; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use tempfile::TempDir; + +struct Disk { + cache: DiskCache>, + _directory: TempDir, +} + +#[fixture] +fn disk() -> Disk { + let directory = tempfile::tempdir().unwrap(); + Disk { + cache: DiskCache::open(directory.path(), JsonCodec::new()).unwrap(), + _directory: directory, + } +} + +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext::default() +} + +const PREFIX: &str = "contract:"; + +#[rstest] +#[tokio::test] +async fn hit_and_miss(disk: Disk, context: ExactCacheContext) { + contract::hit_and_miss(&disk.cache, context, PREFIX, json!({"answer": 42})).await; +} + +#[rstest] +#[tokio::test] +async fn sync_async_equivalence(disk: Disk, context: ExactCacheContext) { + contract::sync_async_equivalence(&disk.cache, context, PREFIX, json!("first"), json!([2])) + .await; +} + +#[rstest] +#[tokio::test] +async fn overwrite_replaces(disk: Disk, context: ExactCacheContext) { + contract::overwrite_replaces(&disk.cache, context, PREFIX, json!(1), json!({"b": 2})).await; +} + +#[rstest] +#[tokio::test] +async fn pipeline_writes_every_entry(disk: Disk, context: ExactCacheContext) { + contract::pipeline_writes_every_entry( + &disk.cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; +} + +#[rstest] +#[tokio::test] +async fn batch_preserves_order(disk: Disk, context: ExactCacheContext) { + contract::batch_preserves_order(&disk.cache, context, PREFIX, json!("first"), json!(2)).await; +} + +#[rstest] +#[tokio::test] +async fn delete_removes_key(disk: Disk, context: ExactCacheContext) { + contract::delete_removes_key(&disk.cache, context, PREFIX, json!("value")).await; +} + +#[rstest] +#[tokio::test] +async fn flush_clears(disk: Disk, context: ExactCacheContext) { + contract::flush_clears(&disk.cache, context, PREFIX, json!("value")).await; +} + +#[rstest] +#[tokio::test] +async fn counter_accumulates(disk: Disk, context: ExactCacheContext) { + contract::counter_accumulates(&disk.cache, context, PREFIX).await; +} diff --git a/litellm-rust/crates/cache-gcs/Cargo.toml b/litellm-rust/crates/cache-gcs/Cargo.toml index 4ec60bcfa3b..da0acf554f9 100644 --- a/litellm-rust/crates/cache-gcs/Cargo.toml +++ b/litellm-rust/crates/cache-gcs/Cargo.toml @@ -15,6 +15,8 @@ reqwest.workspace = true tokio.workspace = true [dev-dependencies] +litellm-cache-testing.workspace = true +rstest.workspace = true serde_json.workspace = true tokio.workspace = true wiremock = "0.6.5" diff --git a/litellm-rust/crates/cache-gcs/src/cache.rs b/litellm-rust/crates/cache-gcs/src/cache.rs index 65282ac99d5..a8a7fbc9a7b 100644 --- a/litellm-rust/crates/cache-gcs/src/cache.rs +++ b/litellm-rust/crates/cache-gcs/src/cache.rs @@ -2,7 +2,7 @@ use std::{future::Future, sync::Arc, time::Duration}; use futures_util::future::try_join_all; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheCodec, CacheConnectionResult, Error, ExactCacheContext, + BaseCache, BatchCache, BatchEntry, CacheCodec, DisconnectCache, Error, ExactCacheContext, FlushCache, }; use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_encode}; @@ -53,25 +53,25 @@ pub struct GcsCache { } impl GcsCache { - pub fn new(config: GcsConfig, codec: S) -> Result { + pub fn new(config: GcsConfig, client: Client, codec: S) -> Self { let token = Arc::new(GcpTokenSource::new(config.path_service_account.clone())); - Self::with_token_source(config, codec, token) + Self::with_token_source(config, client, codec, token) } pub fn with_token_source( config: GcsConfig, + client: Client, codec: S, token: Arc, - ) -> Result { - let client = Client::builder().build().map_err(|_| Error::Unavailable)?; + ) -> Self { let key_prefix = key_prefix(config.gcs_path.as_deref()); - Ok(Self { + Self { config, key_prefix, client, token, codec, - }) + } } pub fn bucket_name(&self) -> &str { @@ -154,26 +154,26 @@ impl GcsCache { F: Future> + Send, T: Send, { - let run = || { + let run = |future: F| { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|_| Error::Unavailable) .and_then(|runtime| runtime.block_on(future)) }; - if let Ok(handle) = tokio::runtime::Handle::try_current() { - if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread { - return tokio::task::block_in_place(run); + match tokio::runtime::Handle::try_current() { + Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => { + tokio::task::block_in_place(|| handle.block_on(future)) } - return std::thread::scope(|scope| { + Ok(_) => std::thread::scope(|scope| { scope - .spawn(run) + .spawn(|| run(future)) .join() .map_err(|_| Error::Unavailable) .and_then(|result| result) - }); + }), + Err(_) => run(future), } - run() } } @@ -222,14 +222,12 @@ impl BaseCache for GcsCache { .await .map(|_| ()) } +} +impl DisconnectCache for GcsCache { async fn disconnect(&self) -> Result<(), Error> { Ok(()) } - - async fn test_connection(&self) -> Result { - Err(Error::UnsupportedOperation) - } } impl BatchCache for GcsCache { diff --git a/litellm-rust/crates/cache-gcs/tests/cache.rs b/litellm-rust/crates/cache-gcs/tests/cache.rs index 45eecf01cec..cdce6a00bdd 100644 --- a/litellm-rust/crates/cache-gcs/tests/cache.rs +++ b/litellm-rust/crates/cache-gcs/tests/cache.rs @@ -1,37 +1,32 @@ -use std::{sync::Arc, time::Duration}; +mod support; + +use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheContext, Error, ExactCacheContext, FlushCache, - JsonCodec, + BaseCache, BatchCache, BatchEntry, CacheContext, DisconnectCache, Error, ExactCacheContext, + FlushCache, }; -use litellm_cache_gcs::{GcsCache, GcsConfig, StaticTokenSource, TokenSource, key_prefix}; -use serde_json::json; +use litellm_cache_gcs::{GcsCache, GcsConfig, TokenSource, key_prefix}; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use support::FakeBucket; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{body_bytes, header, method, path, query_param}, }; -fn config(server: &MockServer, gcs_path: Option<&str>) -> GcsConfig { - GcsConfig { - bucket_name: "bucket".into(), - gcs_path: gcs_path.map(str::to_string), - path_service_account: None, - endpoint: server.uri(), - } +#[fixture] +async fn server() -> MockServer { + MockServer::start().await } -fn cache(server: &MockServer, gcs_path: Option<&str>) -> GcsCache> { - GcsCache::with_token_source( - config(server, gcs_path), - JsonCodec::new(), - Arc::new(StaticTokenSource("tok".into())), - ) - .unwrap() +fn context() -> ExactCacheContext { + ExactCacheContext::default() } +#[rstest] #[tokio::test] -async fn set_writes_encoded_object_and_headers() { - let server = MockServer::start().await; +async fn set_writes_encoded_object_and_headers(#[future(awt)] server: MockServer) { Mock::given(method("POST")) .and(path("/upload/storage/v1/b/bucket/o")) .and(query_param("uploadType", "media")) @@ -42,12 +37,8 @@ async fn set_writes_encoded_object_and_headers() { .expect(1) .mount(&server) .await; - cache(&server, Some("cache/")) - .set_cache( - "team:a b/c", - json!({"value": "entry"}), - &ExactCacheContext::default(), - ) + support::cache(&server, Some("cache/")) + .set_cache("team:a b/c", json!({"value": "entry"}), &context()) .unwrap(); let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 1); @@ -57,103 +48,108 @@ async fn set_writes_encoded_object_and_headers() { ); } +#[rstest] +#[case::hit( + "hit", + ResponseTemplate::new(200).set_body_json(json!({"value": "entry"})), + Ok(Some(json!({"value": "entry"}))) +)] +#[case::missing("missing", ResponseTemplate::new(404), Ok(None))] +#[case::server_error("server-error", ResponseTemplate::new(500), Err(Error::Unavailable))] +#[case::invalid( + "invalid", + ResponseTemplate::new(200).set_body_string("not json"), + Err(Error::InvalidEntry) +)] #[tokio::test] -async fn get_maps_statuses_and_decode_failures() { - let server = MockServer::start().await; +async fn get_maps_statuses_and_decode_failures( + #[future(awt)] server: MockServer, + #[case] key: &str, + #[case] response: ResponseTemplate, + #[case] expected: Result, Error>, +) { Mock::given(method("GET")) - .and(path("/storage/v1/b/bucket/o/hit")) + .and(path(format!("/storage/v1/b/bucket/o/{key}"))) .and(query_param("alt", "media")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value": "entry"}))) + .respond_with(response) .mount(&server) .await; - Mock::given(method("GET")) - .and(path("/storage/v1/b/bucket/o/missing")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/storage/v1/b/bucket/o/server-error")) - .respond_with(ResponseTemplate::new(500)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/storage/v1/b/bucket/o/invalid")) - .respond_with(ResponseTemplate::new(200).set_body_string("not json")) - .mount(&server) - .await; - - let cache = cache(&server, None); - assert_eq!( - cache - .get_cache("hit", &ExactCacheContext::default()) - .unwrap(), - Some(json!({"value": "entry"})) - ); - assert_eq!( - cache - .get_cache("missing", &ExactCacheContext::default()) - .unwrap(), - None - ); - assert_eq!( - cache - .get_cache("server-error", &ExactCacheContext::default()) - .unwrap_err(), - Error::Unavailable - ); - assert_eq!( - cache - .get_cache("invalid", &ExactCacheContext::default()) - .unwrap_err(), - Error::InvalidEntry - ); + let cache = support::cache(&server, None); + assert_eq!(cache.get_cache(key, &context()), expected); + assert_eq!(cache.async_get_cache(key, &context()).await, expected); } -#[test] -fn key_prefix_normalizes_paths() { - assert_eq!(key_prefix(None), ""); - assert_eq!(key_prefix(Some("a/b/")), "a/b/"); - assert_eq!(key_prefix(Some("a/b")), "a/b/"); - assert_eq!(key_prefix(Some("")), ""); +#[rstest] +#[case::none(None, "")] +#[case::trailing_slash(Some("a/b/"), "a/b/")] +#[case::no_trailing_slash(Some("a/b"), "a/b/")] +#[case::empty(Some(""), "")] +fn key_prefix_normalizes_paths(#[case] gcs_path: Option<&str>, #[case] expected: &str) { + assert_eq!(key_prefix(gcs_path), expected); } +#[rstest] #[tokio::test] -async fn object_names_use_python_quote_encoding() { - let server = MockServer::start().await; +async fn cache_exposes_its_configuration(#[future(awt)] server: MockServer) { + let cache = GcsCache::new( + GcsConfig { + path_service_account: Some("/secrets/sa.json".into()), + ..support::config(&server, Some("folder")) + }, + reqwest::Client::new(), + litellm_cache::JsonCodec::::new(), + ); + assert_eq!(cache.bucket_name(), "bucket"); + assert_eq!(cache.key_prefix(), "folder/"); + assert_eq!(cache.path_service_account(), Some("/secrets/sa.json")); + assert_eq!(cache.object_name("k"), "folder/k"); +} + +#[rstest] +#[case::punctuation("a~b-c_d.e/f g%h", "uploadType=media&name=p%2Fa~b-c_d.e%2Ff%20g%25h")] +#[case::utf8("ключ", "uploadType=media&name=p%2F%D0%BA%D0%BB%D1%8E%D1%87")] +#[tokio::test] +async fn object_names_use_python_quote_encoding( + #[future(awt)] server: MockServer, + #[case] key: &str, + #[case] query: &str, +) { Mock::given(method("POST")) .and(path("/upload/storage/v1/b/bucket/o")) .and(query_param("uploadType", "media")) .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + support::cache(&server, Some("p/")) + .async_set_cache(key, json!({"value": key}), context()) + .await + .unwrap(); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests[0].url.query(), Some(query)); +} + +#[rstest] +#[tokio::test] +async fn object_names_are_encoded_in_the_download_path(#[future(awt)] server: MockServer) { + Mock::given(method("GET")) + .and(path("/storage/v1/b/bucket/o/p%2Fa%3Ab%20c")) + .and(query_param("alt", "media")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!(1))) .expect(2) .mount(&server) .await; - let cache = cache(&server, Some("p/")); - cache - .set_cache( - "a~b-c_d.e/f g%h", - json!({"value": "punctuation"}), - &ExactCacheContext::default(), - ) - .unwrap(); - cache - .set_cache( - "ключ", - json!({"value": "utf8"}), - &ExactCacheContext::default(), - ) - .unwrap(); - let requests = server.received_requests().await.unwrap(); - let queries: Vec<_> = requests - .iter() - .filter_map(|request| request.url.query()) - .collect(); - assert!(queries.contains(&"uploadType=media&name=p%2Fa~b-c_d.e%2Ff%20g%25h")); - assert!(queries.contains(&"uploadType=media&name=p%2F%D0%BA%D0%BB%D1%8E%D1%87")); + let cache = support::cache(&server, Some("p")); + assert_eq!(cache.get_cache("a:b c", &context()), Ok(Some(json!(1)))); + assert_eq!( + cache.async_get_cache("a:b c", &context()).await, + Ok(Some(json!(1))) + ); } +#[rstest] #[tokio::test] -async fn ignores_ttl_and_writes_pipeline_concurrently() { - let server = MockServer::start().await; +async fn ignores_ttl_and_writes_pipeline_concurrently(#[future(awt)] server: MockServer) { for key in ["one", "two", "three"] { Mock::given(method("POST")) .and(path("/upload/storage/v1/b/bucket/o")) @@ -164,12 +160,10 @@ async fn ignores_ttl_and_writes_pipeline_concurrently() { .mount(&server) .await; } - let cache = cache(&server, None); - assert_eq!(cache.get_ttl(&ExactCacheContext::default()), None); - assert_eq!( - cache.get_ttl(&ExactCacheContext::default().with_ttl(Some(Duration::from_secs(5)))), - None - ); + let cache = support::cache(&server, None); + let with_ttl = context().with_ttl(Some(Duration::from_secs(5))); + assert_eq!(cache.get_ttl(&context()), None); + assert_eq!(cache.get_ttl(&with_ttl), None); cache .async_set_cache_pipeline( vec![ @@ -177,15 +171,15 @@ async fn ignores_ttl_and_writes_pipeline_concurrently() { ("two".into(), json!({"key": "two"})), ("three".into(), json!({"key": "three"})), ], - ExactCacheContext::default().with_ttl(Some(Duration::from_secs(5))), + with_ttl, ) .await .unwrap(); } +#[rstest] #[tokio::test] -async fn async_batch_get_preserves_hits_misses_and_invalid_entries() { - let server = MockServer::start().await; +async fn batch_get_preserves_hits_misses_and_invalid_entries(#[future(awt)] server: MockServer) { Mock::given(method("GET")) .and(path("/storage/v1/b/bucket/o/hit")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value": "entry"}))) @@ -201,122 +195,83 @@ async fn async_batch_get_preserves_hits_misses_and_invalid_entries() { .respond_with(ResponseTemplate::new(200).set_body_string("not json")) .mount(&server) .await; + let cache = support::cache(&server, None); + let keys = vec!["hit".to_string(), "missing".into(), "invalid".into()]; + let expected = vec![ + BatchEntry::Hit(json!({"value": "entry"})), + BatchEntry::Miss, + BatchEntry::Invalid, + ]; + assert_eq!(cache.batch_get_cache(&keys, &context()).unwrap(), expected); assert_eq!( - cache(&server, None) - .async_batch_get_cache( - vec!["hit".into(), "missing".into(), "invalid".into()], - ExactCacheContext::default(), - ) - .await - .unwrap(), - vec![ - BatchEntry::Hit(json!({"value": "entry"})), - BatchEntry::Miss, - BatchEntry::Invalid, - ] + cache.async_batch_get_cache(keys, context()).await.unwrap(), + expected ); } +#[rstest] #[tokio::test] -async fn lifecycle_operations_are_noops_and_connection_test_is_unsupported() { - let server = MockServer::start().await; - let cache = cache(&server, None); +async fn flush_and_disconnect_are_noops_like_python(#[future(awt)] server: MockServer) { + let cache = support::cache(&server, None); assert_eq!(cache.flush_cache(), Ok(())); + assert_eq!(cache.async_flush_cache().await, Ok(())); assert_eq!(cache.disconnect().await, Ok(())); - assert_eq!( - cache.test_connection().await, - Err(Error::UnsupportedOperation) - ); + assert!(server.received_requests().await.unwrap().is_empty()); } -#[test] +fn round_trip(cache: &support::JsonGcsCache) -> Result, Error> { + cache.set_cache("key", json!({"value": "entry"}), &context())?; + cache.get_cache("key", &context()) +} + +#[rstest] fn sync_operations_work_without_an_active_runtime() { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap(); - let server = runtime.block_on(MockServer::start()); - runtime.block_on( - Mock::given(method("POST")) - .and(path("/upload/storage/v1/b/bucket/o")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server), - ); - runtime.block_on( - Mock::given(method("GET")) - .and(path("/storage/v1/b/bucket/o/key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value": "entry"}))) - .mount(&server), - ); - let cache = cache(&server, None); - cache - .set_cache( - "key", - json!({"value": "entry"}), - &ExactCacheContext::default(), - ) - .unwrap(); - assert_eq!( - cache - .get_cache("key", &ExactCacheContext::default()) - .unwrap(), - Some(json!({"value": "entry"})) - ); + let server = runtime.block_on(FakeBucket::serve()); + let cache = support::cache(&server, None); + assert_eq!(round_trip(&cache), Ok(Some(json!({"value": "entry"})))); } +#[rstest] #[tokio::test(flavor = "multi_thread")] async fn sync_operations_work_inside_a_multi_thread_runtime() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/upload/storage/v1/b/bucket/o")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/storage/v1/b/bucket/o/key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"value": "entry"}))) - .mount(&server) - .await; - let cache = cache(&server, None); - cache - .set_cache( - "key", - json!({"value": "entry"}), - &ExactCacheContext::default(), - ) - .unwrap(); - assert_eq!( - cache - .get_cache("key", &ExactCacheContext::default()) - .unwrap(), - Some(json!({"value": "entry"})) - ); + let server = FakeBucket::serve().await; + let cache = support::cache(&server, None); + assert_eq!(round_trip(&cache), Ok(Some(json!({"value": "entry"})))); +} + +#[rstest] +#[tokio::test] +async fn sync_operations_work_inside_a_current_thread_runtime() { + let server = FakeBucket::serve().await; + let cache = support::cache(&server, None); + assert_eq!(round_trip(&cache), Ok(Some(json!({"value": "entry"})))); } struct FailingTokenSource; impl TokenSource for FailingTokenSource { - fn bearer_token( - &self, - ) -> std::pin::Pin> + Send + '_>> - { + fn bearer_token(&self) -> Pin> + Send + '_>> { Box::pin(async { Err(Error::Unavailable) }) } } +#[rstest] #[tokio::test] -async fn token_source_failure_skips_http() { - let server = MockServer::start().await; - let cache = GcsCache::with_token_source( - config(&server, None), - JsonCodec::::new(), - Arc::new(FailingTokenSource), - ) - .unwrap(); +async fn token_source_failure_skips_http(#[future(awt)] server: MockServer) { + let cache = support::cache_with_token(&server, None, Arc::new(FailingTokenSource)); + assert_eq!( + cache.get_cache("key", &context()).unwrap_err(), + Error::Unavailable + ); assert_eq!( cache - .get_cache("key", &ExactCacheContext::default()) + .async_set_cache("key", json!(1), context()) + .await .unwrap_err(), Error::Unavailable ); diff --git a/litellm-rust/crates/cache-gcs/tests/contract.rs b/litellm-rust/crates/cache-gcs/tests/contract.rs new file mode 100644 index 00000000000..5841d0db642 --- /dev/null +++ b/litellm-rust/crates/cache-gcs/tests/contract.rs @@ -0,0 +1,65 @@ +mod support; + +use litellm_cache::ExactCacheContext; +use litellm_cache_testing as contract; +use rstest::{fixture, rstest}; +use serde_json::json; +use support::{FakeBucket, JsonGcsCache}; +use wiremock::MockServer; + +struct Gcs { + cache: JsonGcsCache, + _server: MockServer, +} + +#[fixture] +async fn gcs() -> Gcs { + let server = FakeBucket::serve().await; + Gcs { + cache: support::cache(&server, Some("contract")), + _server: server, + } +} + +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext::default() +} + +const PREFIX: &str = "contract:"; + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn hit_and_miss(#[future(awt)] gcs: Gcs, context: ExactCacheContext) { + contract::hit_and_miss(&gcs.cache, context, PREFIX, json!({"answer": 42})).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn sync_async_equivalence(#[future(awt)] gcs: Gcs, context: ExactCacheContext) { + contract::sync_async_equivalence(&gcs.cache, context, PREFIX, json!("first"), json!([2])).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn overwrite_replaces(#[future(awt)] gcs: Gcs, context: ExactCacheContext) { + contract::overwrite_replaces(&gcs.cache, context, PREFIX, json!(1), json!({"b": 2})).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn pipeline_writes_every_entry(#[future(awt)] gcs: Gcs, context: ExactCacheContext) { + contract::pipeline_writes_every_entry( + &gcs.cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn batch_preserves_order(#[future(awt)] gcs: Gcs, context: ExactCacheContext) { + contract::batch_preserves_order(&gcs.cache, context, PREFIX, json!("first"), json!(2)).await; +} diff --git a/litellm-rust/crates/cache-gcs/tests/support/mod.rs b/litellm-rust/crates/cache-gcs/tests/support/mod.rs new file mode 100644 index 00000000000..6097f0ee1bd --- /dev/null +++ b/litellm-rust/crates/cache-gcs/tests/support/mod.rs @@ -0,0 +1,87 @@ +#![allow(dead_code)] + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use litellm_cache::JsonCodec; +use litellm_cache_gcs::{GcsCache, GcsConfig, StaticTokenSource, TokenSource}; +use percent_encoding::percent_decode_str; +use serde_json::Value; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate, http::Method, matchers::any}; + +pub type JsonGcsCache = GcsCache>; + +pub fn config(server: &MockServer, gcs_path: Option<&str>) -> GcsConfig { + GcsConfig { + bucket_name: "bucket".into(), + gcs_path: gcs_path.map(str::to_string), + path_service_account: None, + endpoint: server.uri(), + } +} + +pub fn cache_with_token( + server: &MockServer, + gcs_path: Option<&str>, + token: Arc, +) -> JsonGcsCache { + GcsCache::with_token_source( + config(server, gcs_path), + reqwest::Client::new(), + JsonCodec::new(), + token, + ) +} + +pub fn cache(server: &MockServer, gcs_path: Option<&str>) -> JsonGcsCache { + cache_with_token(server, gcs_path, Arc::new(StaticTokenSource("tok".into()))) +} + +/// An in-memory bucket speaking the JSON API's media upload and `alt=media` download. +#[derive(Clone, Default)] +pub struct FakeBucket { + objects: Arc>>>, +} + +impl FakeBucket { + pub async fn serve() -> MockServer { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(Self::default()) + .mount(&server) + .await; + server + } +} + +impl Respond for FakeBucket { + fn respond(&self, request: &Request) -> ResponseTemplate { + let mut objects = self.objects.lock().unwrap(); + match request.method { + Method::POST => { + let name = request + .url + .query_pairs() + .find_map(|(key, value)| (key == "name").then(|| value.into_owned())) + .expect("uploads carry the object name"); + objects.insert(name, request.body.clone()); + ResponseTemplate::new(200) + } + Method::GET => { + let encoded = request + .url + .path() + .strip_prefix("/storage/v1/b/bucket/o/") + .expect("downloads address an object"); + let name = percent_decode_str(encoded).decode_utf8().unwrap(); + match objects.get(name.as_ref()) { + Some(body) => ResponseTemplate::new(200).set_body_bytes(body.clone()), + None => ResponseTemplate::new(404), + } + } + _ => ResponseTemplate::new(405), + } + } +} diff --git a/litellm-rust/crates/cache-memory/Cargo.toml b/litellm-rust/crates/cache-memory/Cargo.toml index 86ab01564c8..88124f5401e 100644 --- a/litellm-rust/crates/cache-memory/Cargo.toml +++ b/litellm-rust/crates/cache-memory/Cargo.toml @@ -9,6 +9,6 @@ repository.workspace = true litellm-cache.workspace = true [dev-dependencies] -serde_json.workspace = true +litellm-cache-testing.workspace = true rstest.workspace = true tokio.workspace = true diff --git a/litellm-rust/crates/cache-memory/src/cache.rs b/litellm-rust/crates/cache-memory/src/cache.rs index 85850c1d925..74476559cbe 100644 --- a/litellm-rust/crates/cache-memory/src/cache.rs +++ b/litellm-rust/crates/cache-memory/src/cache.rs @@ -7,8 +7,8 @@ use std::{ }; use litellm_cache::{ - BaseCache, BatchCache, CacheConnectionResult, CacheConnectionStatus, ClaimCache, CounterCache, - DeleteCache, Error, ExactCacheContext, FlushCache, IncrementOperation, SetCache, TtlCache, + BaseCache, BatchCache, ClaimCache, CounterCache, DeleteCache, DisconnectCache, Error, + ExactCacheContext, FlushCache, SetCache, TtlCache, }; const DEFAULT_MAX_SIZE_IN_MEMORY: usize = 200; @@ -75,7 +75,9 @@ impl InMemoryCache { expiration_heap: BinaryHeap::new(), }), max_size_in_memory: max_size_in_memory.unwrap_or(DEFAULT_MAX_SIZE_IN_MEMORY), - default_ttl: default_ttl.unwrap_or(DEFAULT_TTL), + default_ttl: default_ttl + .filter(|ttl| !ttl.is_zero()) + .unwrap_or(DEFAULT_TTL), max_entry_bytes, measure_value, now: Arc::new(now), @@ -91,21 +93,9 @@ impl InMemoryCache { if self.max_size_in_memory == 0 { return Ok(CacheWrite::Disabled); } - if let (Some(limit), Some(measure)) = (self.max_entry_bytes, &self.measure_value) - && measure(&value)? > limit - { - return Ok(CacheWrite::TooLarge); - } let now = (self.now)(); let mut state = self.state.lock().map_err(|_| Error::Unavailable)?; - let key = key.into(); - Self::evict(&mut state, self.max_size_in_memory, now, &key); - let expiration = state.expirations.get(&key).copied(); - if expiration.is_none_or(|expiration| expiration < now) { - Self::set_expiration(&mut state, &key, now + ttl.unwrap_or(self.default_ttl)); - } - state.values.insert(key, value); - Ok(CacheWrite::Stored) + self.store(&mut state, key.into(), value, ttl, now) } pub fn get_cache(&self, key: &str) -> Result, Error> { @@ -121,6 +111,70 @@ impl InMemoryCache { Ok(state.values.get(key).cloned()) } + /// `check_value_size`: whether `value` fits `max_entry_bytes`. Always `true` without a + /// limit and a measure, since typed values have no generic size. + pub fn check_value_size(&self, value: &V) -> Result { + match (self.max_entry_bytes, &self.measure_value) { + (Some(limit), Some(measure)) => Ok(measure(value)? <= limit), + _ => Ok(true), + } + } + + /// `evict_cache`: drops expired entries, then the earliest-expiring ones until a new key + /// fits. + pub fn evict_cache(&self) -> Result<(), Error> { + let now = (self.now)(); + let mut state = self.state.lock().map_err(|_| Error::Unavailable)?; + Self::evict(&mut state, self.max_size_in_memory, now, None); + Ok(()) + } + + /// `evict_element_if_expired`: `true` when `key` had expired and was removed. + pub fn evict_element_if_expired(&self, key: &str) -> Result { + let now = (self.now)(); + let mut state = self.state.lock().map_err(|_| Error::Unavailable)?; + let expired = state + .expirations + .get(key) + .is_some_and(|expiration| *expiration < now); + if expired { + Self::remove(&mut state, key); + } + Ok(expired) + } + + /// `allow_ttl_override`: a write may set the TTL when the key has none or it has passed. + pub fn allow_ttl_override(&self, key: &str) -> Result { + let now = (self.now)(); + Ok(self + .expires_at(key)? + .is_none_or(|expiration| expiration < now)) + } + + /// The number of stored entries, expired ones included until they are evicted. + pub fn len(&self) -> Result { + Ok(self + .state + .lock() + .map_err(|_| Error::Unavailable)? + .values + .len()) + } + + pub fn is_empty(&self) -> Result { + Ok(self.len()? == 0) + } + + /// Entries in the expiration heap, stale ones included; bounded by eviction. + pub fn expiration_heap_len(&self) -> Result { + Ok(self + .state + .lock() + .map_err(|_| Error::Unavailable)? + .expiration_heap + .len()) + } + pub fn max_size_in_memory(&self) -> usize { self.max_size_in_memory } @@ -172,7 +226,9 @@ impl InMemoryCache { Ok(()) } - fn evict(state: &mut CacheState, capacity: usize, now: Duration, key: &str) { + /// Writing an existing `key` never evicts another entry, unlike Python, which pops the + /// earliest-expiring entry whenever the cache is full. + fn evict(state: &mut CacheState, capacity: usize, now: Duration, key: Option<&str>) { while let Some(Reverse((expiration, key))) = state.expiration_heap.peek().cloned() { if state.expirations.get(&key).copied() != Some(expiration) { state.expiration_heap.pop(); @@ -183,7 +239,7 @@ impl InMemoryCache { break; } } - if state.values.contains_key(key) { + if key.is_some_and(|key| state.values.contains_key(key)) { return; } while state.values.len() >= capacity { @@ -209,6 +265,40 @@ impl InMemoryCache { state.values.remove(key); state.expirations.remove(key); } + + /// `get_cache` under the held lock: an expired entry is removed and reads as missing. + fn live(state: &mut CacheState, key: &str, now: Duration) -> Option { + if state + .expirations + .get(key) + .is_some_and(|expiration| *expiration < now) + { + Self::remove(state, key); + } + state.values.get(key).cloned() + } + + /// Python `set_cache` under the held lock: evict first (even when `key` already exists), + /// then skip oversized values, then write, keeping a live key's expiry. + fn store( + &self, + state: &mut CacheState, + key: String, + value: V, + ttl: Option, + now: Duration, + ) -> Result { + Self::evict(state, self.max_size_in_memory, now, None); + if !self.check_value_size(&value)? { + return Ok(CacheWrite::TooLarge); + } + let expiration = state.expirations.get(&key).copied(); + if expiration.is_none_or(|expiration| expiration < now) { + Self::set_expiration(state, &key, now + ttl.unwrap_or(self.default_ttl)); + } + state.values.insert(key, value); + Ok(CacheWrite::Stored) + } } impl ClaimCache for InMemoryCache @@ -227,7 +317,7 @@ where } let now = (self.now)(); let mut state = self.state.lock().map_err(|_| Error::Unavailable)?; - Self::evict(&mut state, self.max_size_in_memory, now, key); + Self::evict(&mut state, self.max_size_in_memory, now, Some(key)); let existing = state .values .get(key) @@ -262,38 +352,12 @@ impl CounterCache for InMemoryCache { } let now = (self.now)(); let mut state = self.state.lock().map_err(|_| Error::Unavailable)?; - Self::evict(&mut state, self.max_size_in_memory, now, key); - let value = state.values.get(key).copied().unwrap_or_default() + amount; - if !state.expirations.contains_key(key) { - Self::set_expiration( - &mut state, - key, - now + self.get_ttl(&context).unwrap_or(self.default_ttl), - ); - } - state.values.insert(key.into(), value); + let value = Self::live(&mut state, key, now).unwrap_or_default() + amount; + self.store(&mut state, key.into(), value, self.get_ttl(&context), now)?; Ok(value) } } -impl InMemoryCache { - pub async fn async_increment_pipeline( - &self, - operations: Vec, - ) -> Result, Error> { - operations - .into_iter() - .map(|operation| { - self.increment_cache( - &operation.key, - operation.amount, - ExactCacheContext { ttl: operation.ttl }, - ) - }) - .collect() - } -} - impl BaseCache for InMemoryCache { type Value = V; type Context = ExactCacheContext; @@ -315,18 +379,12 @@ impl BaseCache for InMemoryCache { fn get_cache(&self, key: &str, _: &ExactCacheContext) -> Result, Error> { self.get_cache(key) } +} +impl DisconnectCache for InMemoryCache { async fn disconnect(&self) -> Result<(), Error> { Ok(()) } - - async fn test_connection(&self) -> Result { - Ok(CacheConnectionResult { - status: CacheConnectionStatus::Success, - message: "In-memory cache connection test successful".into(), - error: None, - }) - } } impl BatchCache for InMemoryCache {} @@ -367,34 +425,9 @@ where } let now = (self.now)(); let mut state = self.state.lock().map_err(|_| Error::Unavailable)?; - Self::evict(&mut state, self.max_size_in_memory, now, key); - let mut stored = state.values.get(key).cloned().unwrap_or_default(); + let mut stored = Self::live(&mut state, key, now).unwrap_or_default(); stored.extend(values.iter().cloned()); - if let (Some(limit), Some(measure)) = (self.max_entry_bytes, &self.measure_value) - && measure(&stored)? > limit - { - return Ok(values); - } - if !state.expirations.contains_key(key) { - Self::set_expiration(&mut state, key, now + ttl.unwrap_or(self.default_ttl)); - } - state.values.insert(key.into(), stored); + self.store(&mut state, key.into(), stored, ttl, now)?; Ok(values) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn repeated_increments_keep_one_heap_entry_per_expiration() { - let cache = InMemoryCache::::new(Some(4), None); - for _ in 0..100 { - cache - .increment_cache("counter", 1.0, ExactCacheContext::default()) - .unwrap(); - } - assert_eq!(cache.state.lock().unwrap().expiration_heap.len(), 1); - } -} diff --git a/litellm-rust/crates/cache-memory/tests/cache.rs b/litellm-rust/crates/cache-memory/tests/cache.rs index 0df0319b990..ce7d6ac8a98 100644 --- a/litellm-rust/crates/cache-memory/tests/cache.rs +++ b/litellm-rust/crates/cache-memory/tests/cache.rs @@ -8,111 +8,296 @@ use std::{ }; use litellm_cache::{ - BaseCache, CacheBackend, CacheConnectionStatus, ClaimCache, CounterCache, DeleteCache, Error, - ExactCacheContext, IncrementOperation, SetCache, get_cache, set_cache, + BaseCache, BatchCache, BatchEntry, CacheBackend, ClaimCache, CounterCache, DeleteCache, + DisconnectCache, Error, ExactCacheContext, FlushCache, IncrementOperation, SetCache, TtlCache, + get_cache, set_cache, }; use litellm_cache_memory::{CacheWrite, InMemoryCache}; use rstest::{fixture, rstest}; +type Clock = Arc; + #[fixture] -fn clock() -> Arc { +fn clock() -> Clock { Arc::new(AtomicU64::new(100)) } -fn cache(clock: Arc, capacity: usize) -> InMemoryCache { +fn cache_with(clock: &Clock, capacity: usize) -> InMemoryCache { + let clock = clock.clone(); InMemoryCache::with_clock(Some(capacity), Some(Duration::from_secs(60)), move || { - Duration::from_secs(clock.load(Ordering::SeqCst)) + Duration::from_millis(clock.load(Ordering::SeqCst) * 1000) }) } +fn cache(clock: &Clock, capacity: usize) -> InMemoryCache { + cache_with(clock, capacity) +} + +fn at(clock: &Clock, seconds: u64) { + clock.store(seconds, Ordering::SeqCst); +} + +fn secs(seconds: u64) -> Option { + Some(Duration::from_secs(seconds)) +} + +fn ttl(seconds: u64) -> ExactCacheContext { + ExactCacheContext { ttl: secs(seconds) } +} + +fn measured(capacity: usize) -> InMemoryCache { + InMemoryCache::with_clock_and_size_measurement( + Some(capacity), + secs(60), + Some(4), + Some(Arc::new(|value: &String| { + if value.is_empty() { + return Err(Error::InvalidEntry); + } + Ok(value.len()) + })), + || Duration::from_secs(100), + ) +} + #[rstest] -fn default_explicit_and_override_ttls_follow_python_rules(clock: Arc) { - let cache = cache(clock.clone(), 4); +fn default_explicit_and_override_ttls_follow_python_rules(clock: Clock) { + let cache = cache(&clock, 4); cache.set_cache("key", "first".into(), None).unwrap(); - assert_eq!( - cache.expires_at("key").unwrap(), - Some(Duration::from_secs(160)) - ); - cache - .set_cache("key", "second".into(), Some(Duration::from_secs(10))) - .unwrap(); - assert_eq!( - cache.expires_at("key").unwrap(), - Some(Duration::from_secs(160)) - ); - clock.store(160, Ordering::SeqCst); + assert_eq!(cache.expires_at("key").unwrap(), secs(160)); + cache.set_cache("key", "second".into(), secs(10)).unwrap(); + assert_eq!(cache.expires_at("key").unwrap(), secs(160)); + at(&clock, 160); assert_eq!(cache.get_cache("key").unwrap(), Some("second".into())); - clock.store(161, Ordering::SeqCst); + at(&clock, 161); assert_eq!(cache.get_cache("key").unwrap(), None); - cache - .set_cache("key", "third".into(), Some(Duration::from_secs(10))) - .unwrap(); - assert_eq!( - cache.expires_at("key").unwrap(), - Some(Duration::from_secs(171)) - ); + assert_eq!(cache.expires_at("key").unwrap(), None); + cache.set_cache("key", "third".into(), secs(10)).unwrap(); + assert_eq!(cache.expires_at("key").unwrap(), secs(171)); } #[rstest] -fn write_at_expiry_boundary_refreshes_ttl(clock: Arc) { - let cache = cache(clock.clone(), 4); - cache - .set_cache("key", "first".into(), Some(Duration::from_secs(10))) - .unwrap(); - clock.store(110, Ordering::SeqCst); - cache - .set_cache("key", "second".into(), Some(Duration::from_secs(10))) - .unwrap(); - assert_eq!( - cache.expires_at("key").unwrap(), - Some(Duration::from_secs(120)) - ); - clock.store(115, Ordering::SeqCst); +#[case::unset(None, secs(600))] +#[case::zero_falls_back_like_python_or(Some(Duration::ZERO), secs(600))] +#[case::explicit(secs(5), secs(5))] +fn default_ttl_falls_back_to_ten_minutes( + #[case] default_ttl: Option, + #[case] expected: Option, +) { + let cache = InMemoryCache::::with_clock(None, default_ttl, || Duration::ZERO); + assert_eq!(cache.get_ttl(&ExactCacheContext::default()), expected); + cache.set_cache("key", "value".into(), None).unwrap(); + assert_eq!(cache.expires_at("key").unwrap(), expected); + assert_eq!(cache.max_size_in_memory(), 200); +} + +#[rstest] +fn write_at_expiry_boundary_refreshes_ttl(clock: Clock) { + let cache = cache(&clock, 4); + cache.set_cache("key", "first".into(), secs(10)).unwrap(); + at(&clock, 110); + cache.set_cache("key", "second".into(), secs(10)).unwrap(); + assert_eq!(cache.expires_at("key").unwrap(), secs(120)); + at(&clock, 115); assert_eq!(cache.get_cache("key").unwrap(), Some("second".into())); } #[rstest] -fn capacity_evicts_earliest_and_ignores_stale_heap_entries(clock: Arc) { - let cache = cache(clock, 2); - cache - .set_cache("early", "a".into(), Some(Duration::from_secs(10))) - .unwrap(); - cache - .set_cache("late", "b".into(), Some(Duration::from_secs(20))) - .unwrap(); +fn expired_key_without_a_read_allows_a_ttl_override(clock: Clock) { + let cache = cache(&clock, 4); + cache.set_cache("key", "first".into(), secs(1)).unwrap(); + assert_eq!(cache.allow_ttl_override("key"), Ok(false)); + at(&clock, 102); + assert_eq!(cache.allow_ttl_override("key"), Ok(true)); + cache.set_cache("key", "second".into(), secs(1)).unwrap(); + assert_eq!(cache.expires_at("key").unwrap(), secs(103)); + assert_eq!(cache.allow_ttl_override("missing"), Ok(true)); +} + +#[rstest] +fn capacity_evicts_earliest_and_ignores_stale_heap_entries(clock: Clock) { + let cache = cache(&clock, 2); + cache.set_cache("early", "a".into(), secs(10)).unwrap(); + cache.set_cache("late", "b".into(), secs(20)).unwrap(); cache.delete_cache("early").unwrap(); - cache - .set_cache("new", "c".into(), Some(Duration::from_secs(30))) - .unwrap(); + cache.set_cache("new", "c".into(), secs(30)).unwrap(); assert_eq!(cache.get_cache("late").unwrap(), Some("b".into())); - cache - .set_cache("last", "d".into(), Some(Duration::from_secs(40))) - .unwrap(); + cache.set_cache("last", "d".into(), secs(40)).unwrap(); assert_eq!(cache.get_cache("late").unwrap(), None); } -#[test] -fn disabled_size_limited_and_validated_writes_are_observable() { - let cache = |capacity| { - InMemoryCache::with_clock_and_size_measurement( - Some(capacity), - Some(Duration::from_secs(60)), - Some(4), - Some(Arc::new(|value: &String| { - if value.is_empty() { - return Err(Error::InvalidEntry); - } - Ok(value.len()) - })), - || Duration::from_secs(100), - ) - }; - let disabled = cache(0); +#[rstest] +fn max_size_is_respected_when_every_item_has_a_long_ttl(clock: Clock) { + let cache = cache(&clock, 3); + for index in 0..3 { + at(&clock, 100 + index); + cache + .set_cache( + format!("key_{index}"), + format!("value_{index}"), + secs(86_400), + ) + .unwrap(); + } + assert_eq!(cache.len(), Ok(3)); + cache + .set_cache("key_3", "value_3".into(), secs(86_400)) + .unwrap(); + assert_eq!(cache.len(), Ok(3)); + assert_eq!(cache.get_cache("key_0").unwrap(), None); + assert_eq!(cache.expires_at("key_0").unwrap(), None); + for key in ["key_1", "key_2", "key_3"] { + assert!(cache.get_cache(key).unwrap().is_some(), "{key}"); + } +} + +#[rstest] +fn expired_items_are_evicted_before_live_ones(clock: Clock) { + let cache = cache(&clock, 3); + cache.set_cache("expired_1", "1".into(), secs(1)).unwrap(); + cache.set_cache("expired_2", "2".into(), secs(1)).unwrap(); + cache + .set_cache("long_lived", "3".into(), secs(86_400)) + .unwrap(); + assert_eq!(cache.len(), Ok(3)); + at(&clock, 102); + cache + .set_cache("new_item", "4".into(), secs(86_400)) + .unwrap(); + assert_eq!(cache.len(), Ok(2)); + assert_eq!(cache.get_cache("long_lived").unwrap(), Some("3".into())); + assert_eq!(cache.get_cache("new_item").unwrap(), Some("4".into())); + for key in ["expired_1", "expired_2"] { + assert_eq!(cache.expires_at(key).unwrap(), None, "{key}"); + } +} + +#[rstest] +fn injected_clock_controls_expiry_and_eviction(clock: Clock) { + let cache = cache(&clock, 2); + at(&clock, 0); + cache + .set_cache("first", "original".into(), secs(10)) + .unwrap(); + at(&clock, 9); + cache.set_cache("second", "survivor".into(), None).unwrap(); + assert_eq!(cache.get_cache("first").unwrap(), Some("original".into())); + at(&clock, 11); + assert_eq!(cache.get_cache("first").unwrap(), None); + cache + .set_cache("third", "replacement".into(), None) + .unwrap(); + assert_eq!(cache.get_cache("second").unwrap(), Some("survivor".into())); + at(&clock, 70); + cache.set_cache("fourth", "new".into(), None).unwrap(); + assert_eq!(cache.get_cache("second").unwrap(), None); assert_eq!( - disabled.set_cache("a", "x".into(), None).unwrap(), + cache.get_cache("third").unwrap(), + Some("replacement".into()) + ); + assert_eq!(cache.get_cache("fourth").unwrap(), Some("new".into())); +} + +#[rstest] +fn rewriting_one_key_keeps_one_heap_entry(clock: Clock) { + let cache = cache(&clock, 10); + for index in 0..1_000 { + cache + .set_cache("hot_key", format!("value_{index}"), secs(60)) + .unwrap(); + } + assert_eq!(cache.expiration_heap_len(), Ok(1)); +} + +#[rstest] +fn repeated_increments_keep_one_heap_entry_per_expiration() { + let cache = InMemoryCache::::new(Some(4), None); + for _ in 0..100 { + cache + .increment_cache("counter", 1.0, ExactCacheContext::default()) + .unwrap(); + } + assert_eq!(cache.expiration_heap_len(), Ok(1)); +} + +#[rstest] +fn reinserting_expired_keys_below_capacity_prunes_the_heap(clock: Clock) { + let cache = cache(&clock, 200); + for cycle in 0..3 { + for index in 0..5 { + cache + .set_cache(format!("key_{index}"), format!("value_{cycle}"), secs(1)) + .unwrap(); + } + at(&clock, 100 + 2 * (cycle + 1)); + } + for index in 0..5 { + cache + .set_cache(format!("key_{index}"), "final".into(), secs(1)) + .unwrap(); + } + assert_eq!(cache.len(), Ok(5)); + assert_eq!(cache.expiration_heap_len(), Ok(5)); +} + +#[rstest] +fn evict_cache_drops_expired_entries_then_makes_room(clock: Clock) { + let cache = cache(&clock, 2); + assert_eq!(cache.is_empty(), Ok(true)); + cache.set_cache("short", "a".into(), secs(1)).unwrap(); + cache.set_cache("long", "b".into(), secs(50)).unwrap(); + at(&clock, 102); + cache.evict_cache().unwrap(); + assert_eq!(cache.len(), Ok(1)); + assert_eq!(cache.expires_at("short").unwrap(), None); + cache.set_cache("longer", "c".into(), secs(90)).unwrap(); + cache.evict_cache().unwrap(); + assert_eq!(cache.len(), Ok(1)); + assert_eq!(cache.get_cache("long").unwrap(), None); + assert_eq!(cache.get_cache("longer").unwrap(), Some("c".into())); +} + +#[rstest] +fn evict_element_if_expired_reports_removal(clock: Clock) { + let cache = cache(&clock, 4); + cache.set_cache("key", "value".into(), secs(10)).unwrap(); + assert_eq!(cache.evict_element_if_expired("key"), Ok(false)); + assert_eq!(cache.evict_element_if_expired("missing"), Ok(false)); + at(&clock, 110); + assert_eq!(cache.evict_element_if_expired("key"), Ok(false)); + at(&clock, 111); + assert_eq!(cache.evict_element_if_expired("key"), Ok(true)); + assert_eq!(cache.len(), Ok(0)); + assert_eq!(cache.expires_at("key").unwrap(), None); +} + +#[rstest] +#[case::fits("ok", Ok(true))] +#[case::at_limit("four", Ok(true))] +#[case::too_large("oversized", Ok(false))] +#[case::measure_error("", Err(Error::InvalidEntry))] +fn check_value_size_applies_the_entry_limit( + #[case] value: &str, + #[case] expected: Result, +) { + assert_eq!(measured(2).check_value_size(&value.to_string()), expected); +} + +#[rstest] +fn values_are_unbounded_without_a_measure() { + let cache = InMemoryCache::::default(); + assert_eq!(cache.max_entry_bytes(), None); + assert_eq!(cache.check_value_size(&"x".repeat(1 << 20)), Ok(true)); +} + +#[rstest] +fn disabled_size_limited_and_validated_writes_are_observable() { + assert_eq!( + measured(0).set_cache("a", "x".into(), None).unwrap(), CacheWrite::Disabled ); - let cache = cache(2); + let cache = measured(2); + assert_eq!(cache.max_entry_bytes(), Some(4)); assert_eq!( cache.set_cache("large", "oversized".into(), None).unwrap(), CacheWrite::TooLarge @@ -132,30 +317,21 @@ fn disabled_size_limited_and_validated_writes_are_observable() { assert_eq!(cache.get_cache("small").unwrap(), None); } +#[rstest] #[tokio::test] -async fn connection_test_matches_python_result_contract() { +async fn disconnect_is_a_no_op_that_keeps_entries() { let cache = InMemoryCache::::default(); - let result = BaseCache::test_connection(&cache).await.unwrap(); - assert_eq!(result.status, CacheConnectionStatus::Success); - assert_eq!(result.message, "In-memory cache connection test successful"); - assert_eq!(result.error, None); - assert_eq!( - serde_json::to_value(result).unwrap(), - serde_json::json!({ - "status": "success", - "message": "In-memory cache connection test successful" - }) - ); + cache.set_cache("key", "value".into(), None).unwrap(); + cache.disconnect().await.unwrap(); + assert_eq!(cache.get_cache("key").unwrap(), Some("value".into())); } +#[rstest] #[tokio::test] -async fn generic_consumers_share_typed_values_and_honor_expiration() { - let clock = clock(); - let cache: CacheBackend> = Arc::new(cache(clock.clone(), 4)); +async fn generic_consumers_share_typed_values_and_honor_expiration(clock: Clock) { + let cache: CacheBackend> = Arc::new(self::cache(&clock, 4)); let reader = Arc::clone(&cache); - let context = ExactCacheContext { - ttl: Some(Duration::from_secs(5)), - }; + let context = ttl(5); set_cache(cache.as_ref(), "sync", "first".into(), &context).unwrap(); assert_eq!( get_cache(reader.as_ref(), "sync", &context).unwrap(), @@ -181,7 +357,7 @@ async fn generic_consumers_share_typed_values_and_honor_expiration() { reader.async_get_cache("async", &context).await.unwrap(), None ); - clock.store(106, Ordering::SeqCst); + at(&clock, 106); assert_eq!(get_cache(reader.as_ref(), "sync", &context).unwrap(), None); assert_eq!( reader.async_get_cache("batch", &context).await.unwrap(), @@ -189,34 +365,95 @@ async fn generic_consumers_share_typed_values_and_honor_expiration() { ); } -#[test] -fn claims_are_atomic_and_refresh_eligible_winners() { - let clock = clock(); - let cache = InMemoryCache::with_clock(Some(4), Some(Duration::from_secs(60)), { - let clock = clock.clone(); - move || Duration::from_secs(clock.load(Ordering::SeqCst)) - }); - let context = ExactCacheContext { - ttl: Some(Duration::from_secs(10)), - }; +#[rstest] +#[case::context_ttl(ttl(5), secs(105))] +#[case::default_ttl(ExactCacheContext::default(), secs(160))] +#[tokio::test] +async fn pipeline_writes_use_the_context_ttl_or_the_default( + clock: Clock, + #[case] context: ExactCacheContext, + #[case] expected: Option, +) { + let cache = cache(&clock, 4); + cache + .async_set_cache_pipeline( + vec![("a".into(), "1".into()), ("b".into(), "2".into())], + context, + ) + .await + .unwrap(); + assert_eq!(cache.expires_at("a").unwrap(), expected); + assert_eq!(cache.expires_at("b").unwrap(), expected); +} + +#[rstest] +#[tokio::test] +async fn batch_reads_return_one_entry_per_key_and_drop_expired_ones(clock: Clock) { + let cache = cache(&clock, 4); + cache.set_cache("short", "a".into(), secs(1)).unwrap(); + cache.set_cache("long", "b".into(), secs(50)).unwrap(); + let keys = vec!["short".to_string(), "missing".into(), "long".into()]; + assert_eq!( + cache + .batch_get_cache(&keys, &ExactCacheContext::default()) + .unwrap(), + [ + BatchEntry::Hit("a".to_string()), + BatchEntry::Miss, + BatchEntry::Hit("b".into()), + ] + ); + at(&clock, 102); + assert_eq!( + cache + .async_batch_get_cache(keys, ExactCacheContext::default()) + .await + .unwrap(), + [ + BatchEntry::Miss, + BatchEntry::Miss, + BatchEntry::Hit("b".into()) + ] + ); +} + +#[rstest] +#[tokio::test] +async fn flush_clears_values_and_expirations(clock: Clock) { + let cache = cache(&clock, 4); + cache.set_cache("a", "1".into(), None).unwrap(); + cache.set_cache("b", "2".into(), None).unwrap(); + cache.flush_cache().unwrap(); + assert_eq!(cache.len(), Ok(0)); + assert_eq!(cache.expiration_heap_len(), Ok(0)); + cache.set_cache("c", "3".into(), None).unwrap(); + FlushCache::async_flush_cache(&cache).await.unwrap(); + assert_eq!(cache.is_empty(), Ok(true)); + assert_eq!( + cache.async_get_oldest_n_keys(5).await.unwrap(), + Vec::::new() + ); +} + +#[rstest] +fn claims_are_atomic_and_refresh_eligible_winners(clock: Clock) { + let cache = cache(&clock, 4); + let context = ttl(10); assert_eq!( cache .claim_cache("affinity", "first".to_string(), &[], context.clone()) .unwrap(), "first" ); - clock.store(103, Ordering::SeqCst); + at(&clock, 103); assert_eq!( cache .claim_cache("affinity", "second".to_string(), &[], context.clone()) .unwrap(), "first" ); - assert_eq!( - cache.expires_at("affinity").unwrap(), - Some(Duration::from_secs(110)) - ); - clock.store(105, Ordering::SeqCst); + assert_eq!(cache.expires_at("affinity").unwrap(), secs(110)); + at(&clock, 105); assert_eq!( cache .claim_cache( @@ -228,13 +465,10 @@ fn claims_are_atomic_and_refresh_eligible_winners() { .unwrap(), "first" ); - assert_eq!( - cache.expires_at("affinity").unwrap(), - Some(Duration::from_secs(115)) - ); + assert_eq!(cache.expires_at("affinity").unwrap(), secs(115)); } -#[test] +#[rstest] fn counters_increment_under_one_lock() { let cache = InMemoryCache::::default(); assert_eq!( @@ -250,44 +484,107 @@ fn counters_increment_under_one_lock() { } #[rstest] -fn rewriting_an_existing_key_at_capacity_keeps_other_entries(clock: Arc) { - let cache = cache(clock, 2); - cache - .set_cache("hot", "1".into(), Some(Duration::from_secs(10))) - .unwrap(); - cache - .set_cache("cold", "2".into(), Some(Duration::from_secs(20))) - .unwrap(); +fn concurrent_increments_are_atomic() { + let cache = Arc::new(InMemoryCache::::default()); + cache.set_cache("counter", 1000.0, None).unwrap(); + let threads = (0..8) + .map(|_| { + let cache = cache.clone(); + std::thread::spawn(move || { + cache + .increment_cache("counter", 1.0, ExactCacheContext::default()) + .unwrap() + }) + }) + .collect::>(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!(cache.get_cache("counter").unwrap(), Some(1008.0)); +} + +#[rstest] +#[case::window_semantics(false)] +#[case::refresh_ttl_is_ignored(true)] +#[tokio::test] +async fn async_increment_delegates_to_the_locked_sync_path( + clock: Clock, + #[case] refresh_ttl: bool, +) { + let cache = cache_with::(&clock, 4); + assert_eq!( + cache + .async_increment("counter", 2.0, ttl(10), refresh_ttl) + .await, + Ok(2.0) + ); + at(&clock, 105); + assert_eq!( + cache + .async_increment("counter", 3.0, ttl(10), refresh_ttl) + .await, + Ok(5.0) + ); + assert_eq!(cache.get_cache("counter").unwrap(), Some(5.0)); + assert_eq!(cache.expires_at("counter").unwrap(), secs(110)); +} + +#[rstest] +fn expired_counters_restart_from_zero_with_a_new_ttl(clock: Clock) { + let cache = cache_with::(&clock, 4); + cache.increment_cache("counter", 2.0, ttl(10)).unwrap(); + at(&clock, 111); + assert_eq!(cache.increment_cache("counter", 1.0, ttl(10)), Ok(1.0)); + assert_eq!(cache.expires_at("counter").unwrap(), secs(121)); +} + +/// Python `InMemoryCache.set_cache` runs `evict_cache()` before every insert, and step 2 evicts +/// the earliest expiry while `len(cache_dict) >= max_size_in_memory`, even when the key being +/// written already exists. +#[rstest] +fn overwriting_an_existing_key_at_capacity_evicts_the_earliest_expiry_like_python(clock: Clock) { + let cache = cache(&clock, 2); + cache.set_cache("hot", "1".into(), secs(10)).unwrap(); + cache.set_cache("cold", "2".into(), secs(20)).unwrap(); cache.set_cache("cold", "3".into(), None).unwrap(); - assert_eq!(cache.get_cache("hot").unwrap(), Some("1".into())); + + assert_eq!(cache.get_cache("hot").unwrap(), None); assert_eq!(cache.get_cache("cold").unwrap(), Some("3".into())); +} + +/// `claim_cache` has no Python counterpart; it never evicts another entry for a key it holds. +#[rstest] +fn claiming_an_existing_key_at_capacity_keeps_other_entries(clock: Clock) { + let cache = cache(&clock, 2); + cache.set_cache("hot", "1".into(), secs(10)).unwrap(); + cache.set_cache("cold", "2".into(), secs(20)).unwrap(); cache .claim_cache("cold", "4".into(), &[], ExactCacheContext::default()) .unwrap(); assert_eq!(cache.get_cache("hot").unwrap(), Some("1".into())); - - cache.set_cache("new", "5".into(), None).unwrap(); - assert_eq!(cache.get_cache("hot").unwrap(), None); - assert_eq!(cache.get_cache("cold").unwrap(), Some("3".into())); - assert_eq!(cache.get_cache("new").unwrap(), Some("5".into())); + assert_eq!(cache.get_cache("cold").unwrap(), Some("2".into())); } -#[test] -fn incrementing_an_existing_counter_at_capacity_keeps_every_counter() { - let cache = InMemoryCache::::new(Some(2), None); +/// Python `increment_cache` is `get_cache` then `set_cache`, so at capacity the write evicts +/// the earliest expiry first: equal expiries tie-break on the key, and the value read before +/// eviction is the one written back. +#[rstest] +fn incrementing_at_capacity_evicts_the_earliest_expiry_like_python(clock: Clock) { + let cache = cache_with::(&clock, 2); for key in ["a", "b", "a", "b"] { cache .increment_cache(key, 1.0, ExactCacheContext::default()) .unwrap(); } - assert_eq!(cache.get_cache("a").unwrap(), Some(2.0)); + assert_eq!(cache.get_cache("a").unwrap(), None); assert_eq!(cache.get_cache("b").unwrap(), Some(2.0)); } -#[test] -fn disabled_cache_does_not_retain_claims_or_counters() { +#[rstest] +#[tokio::test] +async fn disabled_cache_does_not_retain_claims_counters_or_sets() { let claims = InMemoryCache::::new(Some(0), None); assert_eq!( claims @@ -305,62 +602,128 @@ fn disabled_cache_does_not_retain_claims_or_counters() { 2.0 ); assert_eq!(counters.get_cache("key").unwrap(), None); + + let sets = InMemoryCache::>::new(Some(0), None); + assert_eq!( + sets.async_set_cache_sadd("key", vec!["a".into()], None) + .await + .unwrap(), + ["a"] + ); + assert_eq!(sets.get_cache("key").unwrap(), None); } +#[rstest] #[tokio::test] -async fn ttl_and_oldest_key_operations_use_the_stored_expirations() { - let clock = Arc::new(AtomicU64::new(100)); - let cache = cache(clock, 3); - cache - .set_cache("later", "2".into(), Some(Duration::from_secs(20))) - .unwrap(); - cache - .set_cache("first", "1".into(), Some(Duration::from_secs(10))) - .unwrap(); +async fn ttl_and_oldest_key_operations_use_the_stored_expirations(clock: Clock) { + let cache = cache(&clock, 3); + cache.set_cache("later", "2".into(), secs(20)).unwrap(); + cache.set_cache("first", "1".into(), secs(10)).unwrap(); + cache.set_cache("latest", "3".into(), secs(30)).unwrap(); + assert_eq!(cache.async_get_ttl("first").await.unwrap(), secs(110)); assert_eq!( - cache.async_get_ttl("first").await.unwrap(), - Some(Duration::from_secs(110)) + TtlCache::async_get_ttl(&cache, "later").await.unwrap(), + secs(120) ); assert_eq!(cache.async_get_oldest_n_keys(1).await.unwrap(), ["first"]); + assert_eq!( + cache.async_get_oldest_n_keys(10).await.unwrap(), + ["first", "later", "latest"] + ); + assert_eq!( + cache.async_get_oldest_n_keys(0).await.unwrap(), + Vec::::new() + ); assert_eq!(cache.async_get_ttl("missing").await.unwrap(), None); } +#[rstest] #[tokio::test] -async fn increment_pipeline_preserves_operation_order() { - let cache = InMemoryCache::::new(Some(3), None); +async fn increment_pipeline_preserves_operation_order(clock: Clock) { + let cache = cache_with::(&clock, 3); + let operation = |key: &str, amount, ttl| IncrementOperation { + key: key.into(), + amount, + ttl: secs(ttl), + }; assert_eq!( cache .async_increment_pipeline(vec![ - IncrementOperation { - key: "a".into(), - amount: 1.0, - ttl: Some(Duration::from_secs(10)), - }, - IncrementOperation { - key: "a".into(), - amount: 2.0, - ttl: Some(Duration::from_secs(20)), - }, + operation("a", 1.0, 10), + operation("b", 5.0, 30), + operation("a", 2.0, 20), ]) .await .unwrap(), - [1.0, 3.0] + [1.0, 5.0, 3.0] ); assert_eq!(cache.get_cache("a").unwrap(), Some(3.0)); + assert_eq!(cache.expires_at("a").unwrap(), secs(110)); + assert_eq!(cache.expires_at("b").unwrap(), secs(130)); + assert_eq!( + cache.async_increment_pipeline(Vec::new()).await.unwrap(), + Vec::::new() + ); } +#[rstest] #[tokio::test] -async fn set_capability_preserves_python_result_and_deduplicates_storage() { - let cache = InMemoryCache::>::new(None, None); - let inserted = vec!["a".into(), "a".into(), "b".into()]; +async fn set_capability_preserves_python_result_and_deduplicates_storage(clock: Clock) { + let cache = cache_with::>(&clock, 4); + let inserted = vec!["a".to_string(), "a".into(), "b".into()]; assert_eq!( cache - .async_set_cache_sadd("members", inserted.clone(), None) + .async_set_cache_sadd("members", inserted.clone(), secs(10)) .await .unwrap(), inserted ); + assert_eq!( + cache + .async_set_cache_sadd("members", vec!["c".into()], secs(99)) + .await + .unwrap(), + ["c"] + ); + assert_eq!( + cache.get_cache("members").unwrap(), + Some(HashSet::from(["a".into(), "b".into(), "c".into()])) + ); + assert_eq!(cache.expires_at("members").unwrap(), secs(110)); + at(&clock, 111); + cache + .async_set_cache_sadd("members", vec!["d".into()], None) + .await + .unwrap(); + assert_eq!( + cache.get_cache("members").unwrap(), + Some(HashSet::from(["d".into()])) + ); + assert_eq!(cache.expires_at("members").unwrap(), secs(171)); +} + +#[rstest] +#[tokio::test] +async fn oversized_set_additions_are_not_stored() { + let cache = InMemoryCache::>::with_clock_and_size_measurement( + Some(4), + None, + Some(2), + Some(Arc::new(|value: &HashSet| Ok(value.len()))), + || Duration::ZERO, + ); + cache + .async_set_cache_sadd("members", vec!["a".into(), "b".into()], None) + .await + .unwrap(); + assert_eq!( + cache + .async_set_cache_sadd("members", vec!["c".into()], None) + .await + .unwrap(), + ["c"] + ); assert_eq!( cache.get_cache("members").unwrap(), Some(HashSet::from(["a".into(), "b".into()])) diff --git a/litellm-rust/crates/cache-memory/tests/contract.rs b/litellm-rust/crates/cache-memory/tests/contract.rs new file mode 100644 index 00000000000..860de1ed798 --- /dev/null +++ b/litellm-rust/crates/cache-memory/tests/contract.rs @@ -0,0 +1,98 @@ +use std::time::Duration; + +use litellm_cache::ExactCacheContext; +use litellm_cache_memory::InMemoryCache; +use litellm_cache_testing as contract; +use rstest::{fixture, rstest}; + +#[fixture] +fn strings() -> InMemoryCache { + InMemoryCache::new(Some(16), None) +} + +#[fixture] +fn counters() -> InMemoryCache { + InMemoryCache::new(Some(16), None) +} + +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext { + ttl: Some(Duration::from_secs(60)), + } +} + +#[rstest] +#[tokio::test] +async fn hit_and_miss(strings: InMemoryCache, context: ExactCacheContext) { + contract::hit_and_miss(&strings, context, "memory:", "value".into()).await; +} + +#[rstest] +#[tokio::test] +async fn sync_async_equivalence(strings: InMemoryCache, context: ExactCacheContext) { + contract::sync_async_equivalence( + &strings, + context, + "memory:", + "first".into(), + "second".into(), + ) + .await; +} + +#[rstest] +#[tokio::test] +async fn overwrite_replaces(strings: InMemoryCache, context: ExactCacheContext) { + contract::overwrite_replaces( + &strings, + context, + "memory:", + "first".into(), + "second".into(), + ) + .await; +} + +#[rstest] +#[tokio::test] +async fn pipeline_writes_every_entry(strings: InMemoryCache, context: ExactCacheContext) { + contract::pipeline_writes_every_entry( + &strings, + context, + "memory:", + vec!["a".into(), "b".into(), "c".into()], + ) + .await; +} + +#[rstest] +#[tokio::test] +async fn batch_preserves_order(strings: InMemoryCache, context: ExactCacheContext) { + contract::batch_preserves_order( + &strings, + context, + "memory:", + "first".into(), + "second".into(), + ) + .await; +} + +#[rstest] +#[tokio::test] +async fn delete_removes_key(strings: InMemoryCache, context: ExactCacheContext) { + contract::delete_removes_key(&strings, context, "memory:", "value".into()).await; +} + +#[rstest] +#[tokio::test] +async fn flush_clears(strings: InMemoryCache, context: ExactCacheContext) { + contract::flush_clears(&strings, context, "memory:", "value".into()).await; +} + +#[rstest] +#[tokio::test] +async fn counter_accumulates(counters: InMemoryCache, context: ExactCacheContext) { + contract::counter_accumulates(&counters, context, "memory:").await; +} diff --git a/litellm-rust/crates/cache-qdrant-semantic/Cargo.toml b/litellm-rust/crates/cache-qdrant-semantic/Cargo.toml index 09d6a9637f3..950c2db7491 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/Cargo.toml +++ b/litellm-rust/crates/cache-qdrant-semantic/Cargo.toml @@ -17,7 +17,8 @@ tokio.workspace = true uuid.workspace = true [dev-dependencies] -litellm-cache-response.workspace = true +futures-executor = "0.3" +litellm-cache-testing.workspace = true rstest.workspace = true tonic = "0.14" tonic-prost = "0.14" diff --git a/litellm-rust/crates/cache-qdrant-semantic/src/semantic.rs b/litellm-rust/crates/cache-qdrant-semantic/src/cache.rs similarity index 72% rename from litellm-rust/crates/cache-qdrant-semantic/src/semantic.rs rename to litellm-rust/crates/cache-qdrant-semantic/src/cache.rs index fb165ed5a8e..a140e0af174 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/src/semantic.rs +++ b/litellm-rust/crates/cache-qdrant-semantic/src/cache.rs @@ -1,7 +1,8 @@ -use std::future::Future; - use futures_util::future::try_join_all; -use litellm_cache::{BaseCache, CacheCodec, CacheConnectionResult, Error, SemanticCacheContext}; +use litellm_cache::{ + BaseCache, CacheCodec, Error, SemanticCacheContext, + semantic::{Embedder, SemanticCache, SemanticLookup, prompt_from_messages}, +}; use qdrant_client::{ Payload, Qdrant, qdrant::{ @@ -14,26 +15,7 @@ use qdrant_client::{ use serde_json::{Map, Value, json}; use uuid::Uuid; -use crate::prompt_from_messages; - -pub trait Embedder: Send + Sync + 'static { - fn model(&self) -> &str; - fn embed(&self, input: &str) -> impl Future, Error>> + Send; -} - -#[derive(Clone, Debug, PartialEq)] -pub enum Quantization { - Binary, - Scalar, - Product, -} - -pub struct QdrantSemanticConfig { - pub collection_name: String, - pub similarity_threshold: f64, - pub vector_size: u64, - pub quantization: Quantization, -} +use crate::{QdrantSemanticConfig, Quantization}; pub struct QdrantSemanticCache { client: Qdrant, @@ -100,14 +82,9 @@ impl QdrantSemanticCache { &self.embedder } + /// Python reads `kwargs["messages"]` unguarded, so a request without messages fails. fn prompt(context: &SemanticCacheContext) -> Result { - let Some(messages) = context.messages.as_ref().and_then(Value::as_array) else { - return Err(Error::MissingPrompt); - }; - if messages.is_empty() { - return Err(Error::MissingPrompt); - } - Ok(prompt_from_messages(messages)) + prompt_from_messages(context).ok_or(Error::MissingPrompt) } async fn set( @@ -117,7 +94,10 @@ impl QdrantSemanticCache { context: &SemanticCacheContext, ) -> Result<(), Error> { let prompt = Self::prompt(context)?; - let vector = self.embedder.embed(&prompt).await?; + let vector = self + .embedder + .async_embed(&prompt, context.metadata.as_ref()) + .await?; let response = String::from_utf8(self.codec.encode(&value)?).map_err(|_| Error::InvalidEntry)?; let payload = Payload::try_from(json!({ @@ -147,9 +127,12 @@ impl QdrantSemanticCache { &self, key: &str, context: &SemanticCacheContext, - ) -> Result, Error> { + ) -> Result, Error> { let prompt = Self::prompt(context)?; - let vector = self.embedder.embed(&prompt).await?; + let vector = self + .embedder + .async_embed(&prompt, context.metadata.as_ref()) + .await?; let result = self .client .search_points( @@ -171,20 +154,27 @@ impl QdrantSemanticCache { .await .map_err(|_| Error::Unavailable)?; let Some(point) = result.result.into_iter().next() else { - return Ok(None); + return Ok(SemanticLookup::miss(Some(0.0))); }; let payload: Map = Payload::from(point.payload).into(); - if payload.get("litellm_cache_key").and_then(Value::as_str) != Some(key) { - return Ok(None); + if !payload + .get("litellm_cache_key") + .is_some_and(|cached| python_str(cached).as_deref() == Some(key)) + { + return Ok(SemanticLookup::miss(Some(0.0))); } - if f64::from(point.score) < self.config.similarity_threshold { - return Ok(None); + let similarity = f64::from(point.score); + if similarity < self.config.similarity_threshold { + return Ok(SemanticLookup::miss(Some(similarity))); } let response = payload .get("response") .and_then(Value::as_str) .ok_or(Error::InvalidEntry)?; - self.codec.decode(response.as_bytes()).map(Some) + Ok(SemanticLookup { + value: Some(self.codec.decode(response.as_bytes())?), + similarity: Some(similarity), + }) } } @@ -219,7 +209,8 @@ impl BaseCache for QdrantSemanticCache { } fn get_cache(&self, key: &str, context: &Self::Context) -> Result, Error> { - self.runtime.block_on(self.get(key, context)) + self.get_cache_with_similarity(key, context) + .map(|lookup| lookup.value) } async fn async_set_cache( @@ -236,7 +227,7 @@ impl BaseCache for QdrantSemanticCache { key: &str, context: &Self::Context, ) -> Result, Error> { - self.get(key, context).await + self.get(key, context).await.map(|lookup| lookup.value) } async fn async_set_cache_pipeline( @@ -251,12 +242,36 @@ impl BaseCache for QdrantSemanticCache { .await .map(|_| ()) } +} - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) +/// Python stamps the top point's score, even below the threshold, and `0.0` when there is no +/// point or it belongs to another key. A request without messages fails before any search. +impl SemanticCache for QdrantSemanticCache { + fn get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + self.runtime.block_on(self.get(key, context)) } - async fn test_connection(&self) -> Result { - Err(Error::UnsupportedOperation) + async fn async_get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + self.get(key, context).await + } +} + +/// `str(value)` for the scalar payload values `_payload_matches_cache_key` compares; `None` for +/// null (a pre-isolation point without a key) and for containers, which never equal a key. +fn python_str(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(true) => Some("True".into()), + Value::Bool(false) => Some("False".into()), + Value::Null | Value::Array(_) | Value::Object(_) => None, } } diff --git a/litellm-rust/crates/cache-qdrant-semantic/src/config.rs b/litellm-rust/crates/cache-qdrant-semantic/src/config.rs new file mode 100644 index 00000000000..2654c155f23 --- /dev/null +++ b/litellm-rust/crates/cache-qdrant-semantic/src/config.rs @@ -0,0 +1,13 @@ +#[derive(Clone, Debug, PartialEq)] +pub enum Quantization { + Binary, + Scalar, + Product, +} + +pub struct QdrantSemanticConfig { + pub collection_name: String, + pub similarity_threshold: f64, + pub vector_size: u64, + pub quantization: Quantization, +} diff --git a/litellm-rust/crates/cache-qdrant-semantic/src/embedder.rs b/litellm-rust/crates/cache-qdrant-semantic/src/embedder.rs index 47b898d6f4e..340393600f2 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/src/embedder.rs +++ b/litellm-rust/crates/cache-qdrant-semantic/src/embedder.rs @@ -1,11 +1,9 @@ use std::time::Duration; -use litellm_cache::Error; +use litellm_cache::{Error, semantic::Embedder}; use reqwest::Client; use serde_json::Value; -use crate::Embedder; - pub struct OpenAiEmbedder { client: Client, api_base: String, @@ -31,14 +29,16 @@ impl OpenAiEmbedder { timeout: config.timeout, } } -} -impl Embedder for OpenAiEmbedder { - fn model(&self) -> &str { + pub fn model(&self) -> &str { &self.model } +} - async fn embed(&self, input: &str) -> Result, Error> { +/// An OpenAI-compatible `/embeddings` call. It has no router to route on, so `metadata` is +/// unused, and it only embeds asynchronously: sync cache calls block on the cache's runtime. +impl Embedder for OpenAiEmbedder { + async fn async_embed(&self, input: &str, _metadata: Option<&Value>) -> Result, Error> { let request = self .client .post(format!("{}/embeddings", self.api_base)) diff --git a/litellm-rust/crates/cache-qdrant-semantic/src/lib.rs b/litellm-rust/crates/cache-qdrant-semantic/src/lib.rs index 0f346a9155b..3017bc695e2 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/src/lib.rs +++ b/litellm-rust/crates/cache-qdrant-semantic/src/lib.rs @@ -1,7 +1,7 @@ +mod cache; +mod config; mod embedder; -mod prompt; -mod semantic; +pub use cache::QdrantSemanticCache; +pub use config::{QdrantSemanticConfig, Quantization}; pub use embedder::{OpenAiEmbedder, OpenAiEmbedderConfig}; -pub use prompt::prompt_from_messages; -pub use semantic::{Embedder, QdrantSemanticCache, QdrantSemanticConfig, Quantization}; diff --git a/litellm-rust/crates/cache-qdrant-semantic/src/prompt.rs b/litellm-rust/crates/cache-qdrant-semantic/src/prompt.rs deleted file mode 100644 index ef1a2306658..00000000000 --- a/litellm-rust/crates/cache-qdrant-semantic/src/prompt.rs +++ /dev/null @@ -1,59 +0,0 @@ -use serde_json::Value; - -fn search_results_text(search_results: Option<&Value>) -> String { - let Some(Value::Array(results)) = search_results else { - return String::new(); - }; - results - .iter() - .filter_map(Value::as_object) - .flat_map(|result| { - let source = result - .get("source") - .and_then(Value::as_str) - .map(str::to_owned); - let title = result - .get("title") - .and_then(Value::as_str) - .map(str::to_owned); - let content = result - .get("content") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_object) - .filter_map(|block| block.get("text").and_then(Value::as_str).map(str::to_owned)); - let citations = result - .get("citations") - .filter(|value| !value.is_null()) - .map(|value| serde_json::to_string(value).unwrap_or_default()); - source - .into_iter() - .chain(title) - .chain(content) - .chain(citations) - }) - .collect() -} - -pub fn prompt_from_messages(messages: &[Value]) -> String { - messages - .iter() - .filter_map(Value::as_object) - .map(|message| { - let content = match message.get("content") { - Some(Value::String(content)) => content.clone(), - Some(Value::Array(parts)) => parts - .iter() - .filter_map(Value::as_object) - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect(), - _ => String::new(), - }; - format!( - "{content}{}", - search_results_text(message.get("search_results")) - ) - }) - .collect() -} diff --git a/litellm-rust/crates/cache-qdrant-semantic/tests/contract.rs b/litellm-rust/crates/cache-qdrant-semantic/tests/contract.rs new file mode 100644 index 00000000000..896b799decf --- /dev/null +++ b/litellm-rust/crates/cache-qdrant-semantic/tests/contract.rs @@ -0,0 +1,91 @@ +//! `overwrite_replaces` does not apply: like Python, every write upserts a new `uuid4` point, +//! so a second write with the same prompt adds a tie instead of replacing the first. + +mod support; + +use std::future::Future; + +use litellm_cache::{JsonCodec, SemanticCacheContext, semantic::PreparedEmbedding}; +use litellm_cache_qdrant_semantic::{QdrantSemanticCache, QdrantSemanticConfig, Quantization}; +use litellm_cache_testing as contract; +use qdrant_client::Qdrant; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use support::{FakeQdrant, FakeState}; + +type Cache = QdrantSemanticCache>; + +const PREFIX: &str = "contract:"; + +#[fixture] +fn context() -> SemanticCacheContext { + SemanticCacheContext { + messages: Some(json!([{"role": "user", "content": "contract prompt"}])), + ..Default::default() + } +} + +/// Runs a contract against a fresh fake Qdrant. The sync cache methods block on the runtime, so +/// the contract is polled on a blocking thread outside the runtime's own executor. +async fn run(check: F) +where + F: FnOnce(Cache) -> Fut + Send + 'static, + Fut: Future, +{ + let server = FakeQdrant::start(FakeState::default()).await; + let runtime = tokio::runtime::Handle::current(); + let cache = QdrantSemanticCache::connect( + Qdrant::from_url(&server.url()).build().unwrap(), + PreparedEmbedding(vec![0.6, 0.8]), + JsonCodec::new(), + QdrantSemanticConfig { + collection_name: "contract".to_owned(), + similarity_threshold: 0.9, + vector_size: 2, + quantization: Quantization::Binary, + }, + runtime.clone(), + ) + .await + .unwrap(); + tokio::task::spawn_blocking(move || { + let _guard = runtime.enter(); + futures_executor::block_on(check(cache)); + }) + .await + .unwrap(); + server.stop(); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn hit_and_miss(context: SemanticCacheContext) { + run(|cache| async move { + contract::hit_and_miss(&cache, context, PREFIX, json!({"answer": 42})).await; + }) + .await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn sync_async_equivalence(context: SemanticCacheContext) { + run(|cache| async move { + contract::sync_async_equivalence(&cache, context, PREFIX, json!("first"), json!([2])).await; + }) + .await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn pipeline_writes_every_entry(context: SemanticCacheContext) { + run(|cache| async move { + contract::pipeline_writes_every_entry( + &cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; + }) + .await; +} diff --git a/litellm-rust/crates/cache-qdrant-semantic/tests/embedder.rs b/litellm-rust/crates/cache-qdrant-semantic/tests/embedder.rs index 6b09448fde8..de0fab0a66f 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/tests/embedder.rs +++ b/litellm-rust/crates/cache-qdrant-semantic/tests/embedder.rs @@ -3,9 +3,10 @@ use std::{ time::Duration, }; -use litellm_cache::Error; -use litellm_cache_qdrant_semantic::{Embedder, OpenAiEmbedder, OpenAiEmbedderConfig}; -use serde_json::Value; +use litellm_cache::{Error, semantic::Embedder}; +use litellm_cache_qdrant_semantic::{OpenAiEmbedder, OpenAiEmbedderConfig}; +use rstest::rstest; +use serde_json::{Value, json}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener, @@ -98,6 +99,7 @@ fn config(base: String, timeout: Option) -> OpenAiEmbedderConfig { } } +#[rstest] #[tokio::test] async fn posts_embeddings_request_and_parses_vector() { let server = TestHttpServer::response("200 OK", r#"{"data":[{"embedding":[0.1,0.2]}]}"#).await; @@ -108,7 +110,14 @@ async fn posts_embeddings_request_and_parses_vector() { Some(Duration::from_secs(1)), ), ); - assert_eq!(embedder.embed("hello").await.unwrap(), vec![0.1, 0.2]); + assert_eq!(embedder.model(), "test-model"); + assert_eq!( + embedder + .async_embed("hello", Some(&json!({"ignored": true}))) + .await + .unwrap(), + vec![0.1, 0.2] + ); let request = server.request.lock().unwrap().clone().unwrap(); let request_text = String::from_utf8(request).unwrap(); assert!(request_text.starts_with("POST /embeddings HTTP/1.1\r\n")); @@ -120,37 +129,50 @@ async fn posts_embeddings_request_and_parses_vector() { assert_eq!(body["encoding_format"], "float"); } +#[rstest] +#[case::error_status("500 Internal Server Error", "{}", 0, None, Err(Error::Unavailable))] +#[case::timed_out( + "200 OK", + r#"{"data":[{"embedding":[0.1,0.2]}]}"#, + 500, + Some(Duration::from_millis(200)), + Err(Error::Unavailable) +)] +#[case::within_timeout( + "200 OK", + r#"{"data":[{"embedding":[0.1,0.2]}]}"#, + 100, + Some(Duration::from_secs(1)), + Ok(vec![0.1, 0.2]) +)] +#[case::missing_embedding("200 OK", r#"{"data":[]}"#, 0, None, Err(Error::Unavailable))] #[tokio::test] -async fn status_and_timeout_errors_are_unavailable() { - let server = TestHttpServer::response("500 Internal Server Error", "{}").await; - let embedder = OpenAiEmbedder::new(reqwest::Client::new(), config(server.base_url(), None)); - assert_eq!(embedder.embed("hello").await, Err(Error::Unavailable)); - - let server = TestHttpServer::response_after( - "200 OK", - r#"{"data":[{"embedding":[0.1,0.2]}]}"#, - Duration::from_millis(500), - ) - .await; - let embedder = OpenAiEmbedder::new( - reqwest::Client::new(), - config(server.base_url(), Some(Duration::from_millis(200))), - ); - assert_eq!(embedder.embed("hello").await, Err(Error::Unavailable)); - - let server = TestHttpServer::response_after( - "200 OK", - r#"{"data":[{"embedding":[0.1,0.2]}]}"#, - Duration::from_millis(100), - ) - .await; - let embedder = OpenAiEmbedder::new( - reqwest::Client::new(), - config(server.base_url(), Some(Duration::from_secs(1))), - ); - assert_eq!(embedder.embed("hello").await.unwrap(), vec![0.1, 0.2]); +async fn status_timeout_and_body_errors_are_unavailable( + #[case] status: &str, + #[case] body: &str, + #[case] delay_ms: u64, + #[case] timeout: Option, + #[case] expected: Result, Error>, +) { + let server = + TestHttpServer::response_after(status, body, Duration::from_millis(delay_ms)).await; + let embedder = OpenAiEmbedder::new(reqwest::Client::new(), config(server.base_url(), timeout)); + assert_eq!(embedder.async_embed("hello", None).await, expected); } +#[rstest] +fn sync_embedding_is_unsupported() { + let embedder = OpenAiEmbedder::new( + reqwest::Client::new(), + config("http://127.0.0.1:9".to_owned(), None), + ); + assert_eq!( + embedder.embed("hello", None), + Err(Error::UnsupportedOperation) + ); +} + +#[rstest] #[tokio::test] async fn uses_the_injected_client() { let server = TestHttpServer::response("200 OK", r#"{"data":[{"embedding":[0.1,0.2]}]}"#).await; @@ -159,7 +181,10 @@ async fn uses_the_injected_client() { .build() .unwrap(); let embedder = OpenAiEmbedder::new(client, config(server.base_url(), None)); - assert_eq!(embedder.embed("hello").await.unwrap(), vec![0.1, 0.2]); + assert_eq!( + embedder.async_embed("hello", None).await.unwrap(), + vec![0.1, 0.2] + ); let request = server.request.lock().unwrap().clone().unwrap(); let request_text = String::from_utf8(request).unwrap(); assert!(request_text.contains("\r\nuser-agent: litellm-embedder-test\r\n")); diff --git a/litellm-rust/crates/cache-qdrant-semantic/tests/prompt.rs b/litellm-rust/crates/cache-qdrant-semantic/tests/prompt.rs deleted file mode 100644 index 38cd9e2f908..00000000000 --- a/litellm-rust/crates/cache-qdrant-semantic/tests/prompt.rs +++ /dev/null @@ -1,38 +0,0 @@ -use litellm_cache_qdrant_semantic::prompt_from_messages; -use serde_json::json; - -#[test] -fn prompt_matches_python_message_content_rules() { - let messages = vec![ - json!({"role": "user", "content": "hello"}), - json!({ - "role": "user", - "content": [ - {"type": "text", "text": "world"}, - {"type": "image_url", "image_url": {"url": "ignored"}}, - {"type": "text", "text": "!"}, - ], - }), - ]; - - assert_eq!(prompt_from_messages(&messages), "helloworld!"); -} - -#[test] -fn prompt_includes_search_result_text_and_compact_citations() { - let messages = vec![json!({ - "role": "tool", - "content": null, - "search_results": [{ - "source": "source", - "title": "title", - "content": [{"text": "body"}], - "citations": {"page": 1, "section": "intro"}, - }], - })]; - - assert_eq!( - prompt_from_messages(&messages), - r#"sourcetitlebody{"page":1,"section":"intro"}"# - ); -} diff --git a/litellm-rust/crates/cache-qdrant-semantic/tests/qdrant.rs b/litellm-rust/crates/cache-qdrant-semantic/tests/qdrant.rs index c7522c0b313..fe25c503a36 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/tests/qdrant.rs +++ b/litellm-rust/crates/cache-qdrant-semantic/tests/qdrant.rs @@ -1,27 +1,32 @@ -#[path = "support/mod.rs"] mod support; -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; -use litellm_cache::{BaseCache, CacheCodec, CacheContext, Error, SemanticCacheContext}; -use litellm_cache_qdrant_semantic::{ - Embedder, QdrantSemanticCache, QdrantSemanticConfig, Quantization, +use litellm_cache::{ + BaseCache, CacheContext, Error, JsonCodec, SemanticCacheContext, + semantic::{Embedder, SemanticCache, SemanticLookup}, }; -use litellm_cache_response::{ - CacheEntry, CacheKeyInput, ResponseCache, ResponseCacheCodec, ResponseCacheRequest, -}; -use qdrant_client::Payload; +use litellm_cache_qdrant_semantic::{QdrantSemanticCache, QdrantSemanticConfig, Quantization}; use qdrant_client::{ - Qdrant, + Payload, Qdrant, qdrant::{self, CompressionRatio, Distance, PointId, QuantizationType, Value, VectorParams}, }; +use rstest::{fixture, rstest}; use serde_json::{Value as JsonValue, json}; - use support::{FakeQdrant, FakeState, StoredPoint}; +type Calls = Arc)>>>; +type Cache = QdrantSemanticCache>; + +/// Embeds known prompts, fails on anything else, and records every call. #[derive(Clone)] struct FixedEmbedder { vectors: Arc>>, + calls: Calls, } impl FixedEmbedder { @@ -33,16 +38,21 @@ impl FixedEmbedder { .map(|(prompt, vector)| (prompt.to_owned(), vector)) .collect(), ), + calls: Calls::default(), } } } impl Embedder for FixedEmbedder { - fn model(&self) -> &str { - "fixed" - } - - async fn embed(&self, input: &str) -> Result, Error> { + async fn async_embed( + &self, + input: &str, + metadata: Option<&JsonValue>, + ) -> Result, Error> { + self.calls + .lock() + .unwrap() + .push((input.to_owned(), metadata.cloned())); self.vectors.get(input).cloned().ok_or(Error::Unavailable) } } @@ -63,22 +73,20 @@ fn context(prompt: &str) -> SemanticCacheContext { } } -fn value(response: JsonValue) -> CacheEntry { - CacheEntry { - timestamp: Some(1.0), - response, - } +#[fixture] +fn entry() -> JsonValue { + json!({"timestamp": 1.0, "response": {"answer": 42}}) } async fn connect( server: &FakeQdrant, vectors: impl IntoIterator)>, -) -> QdrantSemanticCache { +) -> Cache { let client = Qdrant::from_url(&server.url()).build().unwrap(); QdrantSemanticCache::connect( client, FixedEmbedder::new(vectors), - ResponseCacheCodec, + JsonCodec::new(), config(Quantization::Binary), tokio::runtime::Handle::current(), ) @@ -86,72 +94,70 @@ async fn connect( .unwrap() } +#[rstest] +#[case::binary(Quantization::Binary)] +#[case::scalar(Quantization::Scalar)] +#[case::product(Quantization::Product)] #[tokio::test(flavor = "multi_thread")] -#[expect( - deprecated, - reason = "the test verifies Qdrant's legacy always_ram quantization contract" -)] -async fn connect_sets_collection_quantization_and_index() { - for (quantization, expected) in [ - (Quantization::Binary, 0), - (Quantization::Scalar, 1), - (Quantization::Product, 2), - ] { - let server = FakeQdrant::start(FakeState::default()).await; - let client = Qdrant::from_url(&server.url()).build().unwrap(); - QdrantSemanticCache::connect( - client, - FixedEmbedder::new([]), - ResponseCacheCodec, - config(quantization), - tokio::runtime::Handle::current(), - ) - .await +async fn connect_sets_collection_quantization_and_index(#[case] quantization: Quantization) { + let server = FakeQdrant::start(FakeState::default()).await; + let client = Qdrant::from_url(&server.url()).build().unwrap(); + QdrantSemanticCache::connect( + client, + FixedEmbedder::new([]), + JsonCodec::::new(), + config(quantization.clone()), + tokio::runtime::Handle::current(), + ) + .await + .unwrap(); + let state = server.state.lock().unwrap(); + let request = &state.created_collections[0]; + let Some(qdrant::vectors_config::Config::Params(VectorParams { size, distance, .. })) = request + .vectors_config + .as_ref() + .and_then(|config| config.config.clone()) + else { + panic!("missing vector params"); + }; + assert_eq!(size, 2); + assert_eq!(distance, Distance::Cosine as i32); + let quantization_config = request + .quantization_config + .as_ref() + .unwrap() + .quantization .unwrap(); - let state = server.state.lock().unwrap(); - let request = &state.created_collections[0]; - let Some(qdrant::vectors_config::Config::Params(VectorParams { size, distance, .. })) = - request - .vectors_config - .as_ref() - .and_then(|config| config.config.clone()) - else { - panic!("missing vector params"); - }; - assert_eq!(size, 2); - assert_eq!(distance, Distance::Cosine as i32); - let quantization_config = request - .quantization_config - .as_ref() - .unwrap() - .quantization - .unwrap(); - match (expected, quantization_config) { - (0, qdrant::quantization_config::Quantization::Binary(binary)) => { - assert_eq!(binary.always_ram, Some(false)); - } - (1, qdrant::quantization_config::Quantization::Scalar(scalar)) => { - assert_eq!(scalar.r#type, QuantizationType::Int8 as i32); - assert_eq!(scalar.quantile, Some(0.99)); - assert_eq!(scalar.always_ram, Some(false)); - } - (2, qdrant::quantization_config::Quantization::Product(product)) => { - assert_eq!(product.compression, CompressionRatio::X16 as i32); - assert_eq!(product.always_ram, Some(false)); - } - _ => panic!("unexpected quantization"), + #[expect( + deprecated, + reason = "the test verifies Qdrant's legacy always_ram quantization contract" + )] + match (quantization, quantization_config) { + (Quantization::Binary, qdrant::quantization_config::Quantization::Binary(binary)) => { + assert_eq!(binary.always_ram, Some(false)); } - assert!(state.index_creations >= 1); - assert_eq!(state.field_indexes[0].collection_name, "semantic"); - assert_eq!(state.field_indexes[0].field_name, "litellm_cache_key"); - assert_eq!( - state.field_indexes[0].field_type, - Some(qdrant::FieldType::Keyword as i32) - ); - server.stop(); + (Quantization::Scalar, qdrant::quantization_config::Quantization::Scalar(scalar)) => { + assert_eq!(scalar.r#type, QuantizationType::Int8 as i32); + assert_eq!(scalar.quantile, Some(0.99)); + assert_eq!(scalar.always_ram, Some(false)); + } + (Quantization::Product, qdrant::quantization_config::Quantization::Product(product)) => { + assert_eq!(product.compression, CompressionRatio::X16 as i32); + assert_eq!(product.always_ram, Some(false)); + } + _ => panic!("unexpected quantization"), } + assert!(state.index_creations >= 1); + assert_eq!(state.field_indexes[0].collection_name, "semantic"); + assert_eq!(state.field_indexes[0].field_name, "litellm_cache_key"); + assert_eq!( + state.field_indexes[0].field_type, + Some(qdrant::FieldType::Keyword as i32) + ); + server.stop(); } +#[rstest] #[tokio::test(flavor = "multi_thread")] async fn existing_collection_skips_create_and_index_failure_is_non_fatal() { let server = FakeQdrant::start(FakeState { @@ -160,19 +166,25 @@ async fn existing_collection_skips_create_and_index_failure_is_non_fatal() { ..Default::default() }) .await; - let _cache = connect(&server, [("hello", vec![1.0, 0.0])]).await; + let cache = connect(&server, [("hello", vec![1.0, 0.0])]).await; + assert_eq!(cache.collection_name(), "semantic"); + assert_eq!(cache.similarity_threshold(), 0.9); + assert_eq!(cache.vector_size(), 2); let state = server.state.lock().unwrap(); assert!(state.created_collections.is_empty()); assert!(state.index_creations >= 1); server.stop(); } +#[rstest] #[tokio::test(flavor = "multi_thread")] -async fn async_and_sync_set_get_store_exact_payload() { +async fn async_and_sync_set_get_store_exact_payload(entry: JsonValue) { let server = FakeQdrant::start(FakeState::default()).await; let cache = Arc::new(connect(&server, [("hello", vec![1.0, 0.0])]).await); - let ctx = context("hello"); - let entry = value(json!({"answer": 42})); + let ctx = SemanticCacheContext { + metadata: Some(json!({"tenant": "team"})), + ..context("hello") + }; cache .async_set_cache("key", entry.clone(), ctx.clone()) .await @@ -188,10 +200,8 @@ async fn async_and_sync_set_get_store_exact_payload() { payload_keys.sort(); assert_eq!(payload_keys, ["litellm_cache_key", "response", "text"]); assert_eq!(payload["litellm_cache_key"], Value::from("key")); - assert_eq!( - payload["response"], - Value::from(String::from_utf8(ResponseCacheCodec.encode(&entry).unwrap()).unwrap()) - ); + assert_eq!(payload["text"], Value::from("hello")); + assert_eq!(payload["response"], Value::from(entry.to_string())); } let sync_entry = entry.clone(); let sync_cache = cache.clone(); @@ -207,208 +217,276 @@ async fn async_and_sync_set_get_store_exact_payload() { }) .await .unwrap(); + assert_eq!( + *cache.embedder().calls.lock().unwrap(), + vec![("hello".to_owned(), ctx.metadata.clone()); 4] + ); server.stop(); } +#[rstest] +#[case::content_parts_skip_images( + json!([ + {"role": "user", "content": "hello"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "world"}, + {"type": "image_url", "image_url": {"url": "ignored"}}, + {"type": "text", "text": "!"}, + ], + }, + ]), + "helloworld!" +)] +#[case::search_results_and_compact_citations( + json!([{ + "role": "tool", + "content": null, + "search_results": [{ + "source": "source", + "title": "title", + "content": [{"text": "body"}], + "citations": {"page": 1, "section": "intro"}, + }], + }]), + r#"sourcetitlebody{"page":1,"section":"intro"}"# +)] #[tokio::test(flavor = "multi_thread")] -async fn misses_and_payload_validation_are_safe() { +async fn prompt_matches_python_message_rules( + #[case] messages: JsonValue, + #[case] prompt: &'static str, + entry: JsonValue, +) { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, [(prompt, vec![1.0, 0.0])]).await; + let context = SemanticCacheContext { + messages: Some(messages), + ..Default::default() + }; + + cache.async_set_cache("key", entry, context).await.unwrap(); + + assert_eq!(cache.embedder().calls.lock().unwrap()[0].0, prompt); + assert_eq!( + server.state.lock().unwrap().points[0].payload["text"], + Value::from(prompt) + ); + server.stop(); +} + +#[rstest] +#[case::no_messages(SemanticCacheContext::default())] +#[case::empty_messages(SemanticCacheContext { messages: Some(json!([])), ..Default::default() })] +#[case::responses_input_is_not_read(SemanticCacheContext { input: Some(json!("hello")), ..Default::default() })] +#[tokio::test(flavor = "multi_thread")] +async fn requests_without_messages_are_missing_a_prompt( + #[case] context: SemanticCacheContext, + entry: JsonValue, +) { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, [("hello", vec![1.0, 0.0])]).await; + + assert_eq!( + cache.async_set_cache("key", entry, context.clone()).await, + Err(Error::MissingPrompt) + ); + assert_eq!( + cache.async_get_cache("key", &context).await, + Err(Error::MissingPrompt) + ); + assert!(cache.embedder().calls.lock().unwrap().is_empty()); + server.stop(); +} + +#[rstest] +#[case::other_key("other", "hello", None)] +#[case::below_similarity_threshold("key", "near", None)] +#[tokio::test(flavor = "multi_thread")] +async fn misses_and_payload_validation_are_safe( + #[case] key: &str, + #[case] prompt: &str, + #[case] numeric_key_point: Option, + entry: JsonValue, +) { let server = FakeQdrant::start(FakeState::default()).await; let cache = connect( &server, [("hello", vec![1.0, 0.0]), ("near", vec![0.7, 0.71414286])], ) .await; - let entry = value(json!({"answer": 1})); cache .async_set_cache("key", entry, context("hello")) .await .unwrap(); + if let Some(id) = numeric_key_point { + server.insert_point(StoredPoint { + id: Some(PointId::from(id)), + vector: vec![1.0, 0.0], + payload: Payload::try_from(json!({ + "litellm_cache_key": id, + "response": "{}", + })) + .unwrap() + .into(), + }); + } + assert_eq!( - cache - .async_get_cache("other", &context("hello")) - .await - .unwrap(), - None - ); - assert_eq!( - cache - .async_get_cache("key", &context("near")) - .await - .unwrap(), - None - ); - server.insert_point(StoredPoint { - id: Some(PointId::from(99_u64)), - vector: vec![1.0, 0.0], - payload: Payload::try_from(json!({ - "litellm_cache_key": 99, - "response": "{}", - })) - .unwrap() - .into(), - }); - assert_eq!( - cache - .async_get_cache("99", &context("hello")) - .await - .unwrap(), + cache.async_get_cache(key, &context(prompt)).await.unwrap(), None ); server.stop(); } +#[rstest] +#[case::hit("key", context("hello"), Ok((true, Some(1.0))))] +#[case::below_similarity_threshold("key", context("near"), Ok((false, Some(0.7))))] +#[case::no_results("other", context("hello"), Ok((false, Some(0.0))))] +#[case::no_prompt("key", SemanticCacheContext::default(), Err(Error::MissingPrompt))] #[tokio::test(flavor = "multi_thread")] -async fn decoding_errors_missing_prompt_pipeline_and_ttl_behave_as_required() { +async fn lookup_reports_python_semantic_similarity( + #[case] key: &'static str, + #[case] context: SemanticCacheContext, + #[case] expected: Result<(bool, Option), Error>, + #[values(false, true)] use_async: bool, + entry: JsonValue, +) { let server = FakeQdrant::start(FakeState::default()).await; - let cache = connect(&server, [("one", vec![1.0, 0.0]), ("two", vec![0.0, 1.0])]).await; - let empty = SemanticCacheContext::default(); - assert_eq!( - cache - .async_set_cache("key", value(json!({})), empty.clone()) - .await, - Err(Error::MissingPrompt) + let cache = Arc::new( + connect( + &server, + [("hello", vec![1.0, 0.0]), ("near", vec![0.7, 0.71414286])], + ) + .await, ); + cache + .async_set_cache("key", entry.clone(), self::context("hello")) + .await + .unwrap(); + server.insert_point(StoredPoint { + id: Some(PointId::from(99_u64)), + vector: vec![1.0, 0.0], + payload: Payload::try_from(json!({"litellm_cache_key": 99, "response": "{}"})) + .unwrap() + .into(), + }); + + let lookup = if use_async { + cache.async_get_cache_with_similarity(key, &context).await + } else { + let cache = Arc::clone(&cache); + tokio::task::spawn_blocking(move || cache.get_cache_with_similarity(key, &context)) + .await + .unwrap() + }; + + match (lookup, expected) { + (Ok(SemanticLookup { value, similarity }), Ok((hit, expected))) => { + assert_eq!(value, hit.then_some(entry)); + assert_eq!(similarity.is_some(), expected.is_some()); + if let (Some(similarity), Some(expected)) = (similarity, expected) { + assert!((similarity - expected).abs() < 1e-6, "{similarity}"); + } + } + (lookup, expected) => assert_eq!(lookup.map(|_| ()), expected.map(|_| ())), + } + server.stop(); +} + +#[rstest] +#[case::codec_decodes_the_payload(Some(json!("{\"a\":1}")), Ok(Some(json!({"a": 1}))))] +#[case::undecodable_response(Some(json!("not json")), Err(Error::InvalidEntry))] +#[case::non_string_response(Some(json!(1)), Err(Error::InvalidEntry))] +#[case::missing_response(None, Err(Error::InvalidEntry))] +#[tokio::test(flavor = "multi_thread")] +async fn stored_responses_go_through_the_codec( + #[case] response: Option, + #[case] expected: Result, Error>, +) { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, [("hello", vec![1.0, 0.0])]).await; + let mut payload = serde_json::Map::new(); + payload.insert("litellm_cache_key".to_owned(), json!("key")); + if let Some(response) = response { + payload.insert("response".to_owned(), response); + } + server.insert_point(StoredPoint { + id: Some(PointId::from(1_u64)), + vector: vec![1.0, 0.0], + payload: Payload::try_from(JsonValue::Object(payload)) + .unwrap() + .into(), + }); + assert_eq!( - cache.async_get_cache("key", &empty).await, - Err(Error::MissingPrompt) + cache.async_get_cache("key", &context("hello")).await, + expected ); + server.stop(); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn embedding_failures_propagate() { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, []).await; + assert_eq!( cache.async_get_cache("key", &context("unknown")).await, Err(Error::Unavailable) ); + server.stop(); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn ttl_is_ignored_and_entries_do_not_expire(entry: JsonValue) { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, [("one", vec![1.0, 0.0])]).await; + let ctx = context("one").with_ttl(Some(Duration::from_secs(1))); + + assert_eq!(cache.get_ttl(&ctx), None); cache - .async_set_cache( - "ttl", - value(json!({"ttl": true})), - context("one").with_ttl(Some(Duration::from_secs(1))), - ) + .async_set_cache("ttl", entry, ctx.clone()) .await .unwrap(); tokio::time::sleep(Duration::from_millis(1_100)).await; - assert!( - cache - .async_get_cache( - "ttl", - &context("one").with_ttl(Some(Duration::from_secs(1))), - ) - .await - .unwrap() - .is_some() - ); + assert!(cache.async_get_cache("ttl", &ctx).await.unwrap().is_some()); + server.stop(); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn pipeline_upserts_each_entry_and_waits_for_indexing() { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, [("one", vec![1.0, 0.0])]).await; + cache .async_set_cache_pipeline( vec![ - ("one".to_owned(), value(json!({"n": 1}))), - ("two".to_owned(), value(json!({"n": 2}))), + ("one".to_owned(), json!({"n": 1})), + ("two".to_owned(), json!({"n": 2})), ], context("one"), ) .await .unwrap(); - assert!( - cache - .async_get_cache("one", &context("one")) - .await - .unwrap() - .is_some() - ); - assert!( - cache - .async_get_cache("two", &context("one")) - .await - .unwrap() - .is_some() - ); + + for (key, value) in [("one", json!({"n": 1})), ("two", json!({"n": 2}))] { + assert_eq!( + cache.async_get_cache(key, &context("one")).await.unwrap(), + Some(value) + ); + } assert_eq!( server.state.lock().unwrap().upsert_waits, - vec![Some(true), Some(true), Some(true)] - ); - assert_eq!(cache.get_ttl(&context("one")), None); - assert_eq!( - cache.test_connection().await, - Err(Error::UnsupportedOperation) - ); - server.stop(); -} - -#[tokio::test(flavor = "multi_thread")] -async fn response_payloads_decode_and_invalid_entries_fail() { - let server = FakeQdrant::start(FakeState::default()).await; - let cache = connect(&server, [("hello", vec![1.0, 0.0])]).await; - for (key, response) in [ - ("python", json!("{'timestamp': 1.0, 'response': {'a': 1}}")), - ("garbage", json!("not json")), - ("missing", json!("unused")), - ] { - let mut payload = serde_json::Map::new(); - payload.insert("litellm_cache_key".to_owned(), json!(key)); - if key != "missing" { - payload.insert("response".to_owned(), response); - } - server.insert_point(StoredPoint { - id: Some(PointId::from(key.len() as u64)), - vector: vec![1.0, 0.0], - payload: Payload::try_from(JsonValue::Object(payload)) - .unwrap() - .into(), - }); - } - assert_eq!( - cache - .async_get_cache("python", &context("hello")) - .await - .unwrap(), - Some(value(json!({"a": 1}))) - ); - assert_eq!( - cache.async_get_cache("garbage", &context("hello")).await, - Err(Error::InvalidEntry) - ); - assert_eq!( - cache.async_get_cache("missing", &context("hello")).await, - Err(Error::InvalidEntry) - ); - server.stop(); -} - -#[tokio::test(flavor = "multi_thread")] -async fn response_cache_facade_turns_invalid_entry_into_miss() { - let server = FakeQdrant::start(FakeState::default()).await; - let cache = Arc::new(connect(&server, [("hello", vec![1.0, 0.0])]).await); - let request = ResponseCacheRequest::::new(CacheKeyInput { - preset: Some("key".to_owned()), - ..Default::default() - }) - .with_context(context("hello")); - let response = json!({"answer": 42}); - let facade = ResponseCache::new(cache.clone()); - facade - .async_store(&request, response.clone(), Duration::from_secs(1)) - .await - .unwrap(); - assert_eq!( - facade - .async_lookup(&request, Duration::from_secs(1)) - .await - .unwrap(), - Some(response) - ); - { - let mut state = server.state.lock().unwrap(); - state.points[0] - .payload - .insert("response".to_owned(), Value::from("not json")); - } - assert_eq!( - facade - .async_lookup(&request, Duration::from_secs(1)) - .await - .unwrap(), - None + vec![Some(true), Some(true)] ); server.stop(); } +#[rstest] #[tokio::test(flavor = "multi_thread")] async fn stopped_qdrant_server_maps_to_unavailable() { let server = FakeQdrant::start(FakeState::default()).await; @@ -420,3 +498,28 @@ async fn stopped_qdrant_server_maps_to_unavailable() { Err(Error::Unavailable) ); } + +/// `_payload_matches_cache_key` compares `str(cached_key) == str(key)`, so a point whose stored +/// key is the number 99 answers a lookup for `"99"`. +#[rstest] +#[tokio::test(flavor = "multi_thread")] +async fn numeric_stored_cache_keys_match_like_python_str() { + let server = FakeQdrant::start(FakeState::default()).await; + let cache = connect(&server, [("hello", vec![1.0, 0.0])]).await; + server.insert_point(StoredPoint { + id: Some(PointId::from(99_u64)), + vector: vec![1.0, 0.0], + payload: Payload::try_from(json!({"litellm_cache_key": 99, "response": "{}"})) + .unwrap() + .into(), + }); + + let lookup = cache + .async_get_cache_with_similarity("99", &context("hello")) + .await + .unwrap(); + + assert_eq!(lookup.value, Some(json!({}))); + assert!((lookup.similarity.unwrap() - 1.0).abs() < 1e-6); + server.stop(); +} diff --git a/litellm-rust/crates/cache-qdrant-semantic/tests/support/mod.rs b/litellm-rust/crates/cache-qdrant-semantic/tests/support/mod.rs index 9a556ae7df5..695fceeac44 100644 --- a/litellm-rust/crates/cache-qdrant-semantic/tests/support/mod.rs +++ b/litellm-rust/crates/cache-qdrant-semantic/tests/support/mod.rs @@ -1,15 +1,16 @@ +#![allow(dead_code)] + use std::{ collections::{HashMap, HashSet}, net::SocketAddr, sync::{Arc, Mutex}, }; -use qdrant_client::qdrant::collections_server::CollectionsServer; use qdrant_client::qdrant::{ self, CollectionExists, CollectionExistsRequest, CollectionExistsResponse, CollectionOperationResponse, CreateCollection, CreateFieldIndexCollection, Filter, PointId, PointsOperationResponse, ScoredPoint, SearchPoints, SearchResponse, Value, Vector, Vectors, - collections_server::Collections, + collections_server::{Collections, CollectionsServer}, points_server::{Points, PointsServer}, }; use tokio::sync::oneshot; diff --git a/litellm-rust/crates/cache-redis-semantic/Cargo.toml b/litellm-rust/crates/cache-redis-semantic/Cargo.toml index 9a8755a189e..fe5a317cec0 100644 --- a/litellm-rust/crates/cache-redis-semantic/Cargo.toml +++ b/litellm-rust/crates/cache-redis-semantic/Cargo.toml @@ -8,14 +8,12 @@ repository.workspace = true [dependencies] litellm-cache.workspace = true litellm-cache-redis.workspace = true -litellm-cache-response.workspace = true redis = { version = "1.7.0", features = ["tls-rustls"] } -r2d2 = "0.8.10" -serde_json.workspace = true sha2.workspace = true -tokio.workspace = true [dev-dependencies] +litellm-cache-testing.workspace = true redis-test = "1.0.4" +rstest.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/litellm-rust/crates/cache-redis-semantic/src/cache.rs b/litellm-rust/crates/cache-redis-semantic/src/cache.rs index e0ac31f3630..02feb3cbdf4 100644 --- a/litellm-rust/crates/cache-redis-semantic/src/cache.rs +++ b/litellm-rust/crates/cache-redis-semantic/src/cache.rs @@ -1,105 +1,36 @@ use std::{ - future::Future, - sync::{Arc, OnceLock}, + sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use litellm_cache::{ - BaseCache, CacheCodec, CacheConnectionResult, CacheConnectionStatus, Error, - SemanticCacheContext, + BaseCache, CacheCodec, Error, SemanticCacheContext, + semantic::{Embedder, SemanticCache, SemanticLookup, prompt_from_context}, }; use litellm_cache_redis::{ RedisTopology, connection::{ConnectionRef, Connections}, }; -use litellm_cache_response::{CacheEntry, ResponseCacheCodec}; -use serde_json::Value; use sha2::{Digest, Sha256}; -use crate::prompt::prompt_from_context; - -const CACHE_KEY_FIELD: &str = "litellm_cache_key"; -const VECTOR_FIELD: &str = "prompt_vector"; - -pub trait Embedder: Send + Sync + 'static { - fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error>; - - fn async_embed( - &self, - prompt: &str, - metadata: Option<&Value>, - ) -> impl Future, Error>> + Send; -} - -#[derive(Clone, Debug)] -pub struct RedisSemanticConfig { - pub index_name: String, - pub similarity_threshold: f32, -} +use crate::{ + RedisSemanticConfig, + index::{CACHE_KEY_FIELD, Index, VECTOR_FIELD}, + reply::{bytes_field, first_document, number_field, string_field}, +}; struct Inner { - index_name: String, + index: Index, distance_threshold: f64, - resolved_index: OnceLock, - codec: ResponseCacheCodec, clock: fn() -> f64, } impl Inner { - fn new(config: RedisSemanticConfig) -> Self { + fn new(config: RedisSemanticConfig, clock: fn() -> f64) -> Self { Self { - index_name: config.index_name, + index: Index::new(config.index_name), distance_threshold: 1.0 - f64::from(config.similarity_threshold), - resolved_index: OnceLock::new(), - codec: ResponseCacheCodec, - clock: timestamp, - } - } - - fn ensure_index( - &self, - connection: &mut ConnectionRef<'_>, - dims: usize, - ) -> Result { - if let Some(name) = self.resolved_index.get() { - return Ok(name.clone()); - } - let name = match index_compatible(connection, &self.index_name, dims)? { - Some(true) => self.index_name.clone(), - Some(false) => self.isolated_index(connection, dims)?, - None => match create_index(connection, &self.index_name, dims) { - Ok(()) => self.index_name.clone(), - Err(_) => match index_compatible(connection, &self.index_name, dims)? { - Some(true) => self.index_name.clone(), - Some(false) => self.isolated_index(connection, dims)?, - None => return Err(Error::Unavailable), - }, - }, - }; - let _ = self.resolved_index.set(name.clone()); - Ok(name) - } - - fn isolated_index( - &self, - connection: &mut ConnectionRef<'_>, - dims: usize, - ) -> Result { - let name = format!("{}_isolated", self.index_name); - match index_compatible(connection, &name, dims)? { - Some(true) => Ok(name), - Some(false) => { - redis::cmd("FT.DROPINDEX") - .arg(&name) - .query::<()>(connection) - .map_err(|_| Error::Unavailable)?; - create_index(connection, &name, dims)?; - Ok(name) - } - None => { - create_index(connection, &name, dims)?; - Ok(name) - } + clock, } } @@ -107,15 +38,14 @@ impl Inner { &self, connection: &mut ConnectionRef<'_>, tag: &str, - value: &CacheEntry, + response: Vec, prompt: &str, vector: &[f32], ttl: Option, ) -> Result<(), Error> { - let index = self.ensure_index(connection, vector.len())?; + let index = self.index.ensure(connection, vector.len())?; let entry_id = entry_id(prompt, tag); let hash_key = format!("{index}:{entry_id}"); - let response = self.codec.encode(value)?; redis::cmd("HSET") .arg(&hash_key) .arg("entry_id") @@ -149,8 +79,8 @@ impl Inner { connection: &mut ConnectionRef<'_>, tag: &str, vector: &[f32], - ) -> Result, Error> { - let index = self.ensure_index(connection, vector.len())?; + ) -> Result>, Error> { + let index = self.index.ensure(connection, vector.len())?; let query = format!( "(@{CACHE_KEY_FIELD}:{{{}}})=>[KNN 1 @{VECTOR_FIELD} $vector AS vector_distance]", escape_tag(tag) @@ -183,57 +113,80 @@ impl Inner { .query::(connection) .map_err(|_| Error::Unavailable)?; let Some(fields) = first_document(&result) else { - return Ok(None); + return Ok(SemanticLookup::miss(Some(0.0))); }; if string_field(fields, CACHE_KEY_FIELD).as_deref() != Some(tag) { - return Ok(None); + return Ok(SemanticLookup::miss(Some(0.0))); } - if number_field(fields, "vector_distance") - .is_none_or(|distance| distance > self.distance_threshold) - { - return Ok(None); - } - let Some(response) = bytes_field(fields, "response") else { - return Ok(None); + // redisvl's range query only returns entries within the distance threshold, so a + // farther hit reads as no result. + let Some(distance) = number_field(fields, "vector_distance") + .filter(|distance| *distance <= self.distance_threshold) + else { + return Ok(SemanticLookup::miss(Some(0.0))); }; - self.codec.decode(&response).map(Some) - } -} - -pub struct RedisSemanticCache { - connections: Arc>, - embedder: E, - inner: Arc, -} - -impl RedisSemanticCache { - pub fn new(url: &str, embedder: E, config: RedisSemanticConfig) -> Result { - Ok(Self { - connections: Arc::new(Connections::open(url, &RedisTopology::Standalone)?), - embedder, - inner: Arc::new(Inner::new(config)), + let Some(response) = bytes_field(fields, "response") else { + return Ok(SemanticLookup::miss(Some(0.0))); + }; + Ok(SemanticLookup { + value: Some(response), + similarity: Some(1.0 - distance), }) } } -impl RedisSemanticCache { - pub fn with_connection(connection: C, embedder: E, config: RedisSemanticConfig) -> Self { +/// `RedisSemanticCache`: a redisvl-compatible semantic index on Redis Stack. Values go through +/// the injected codec, so the response layer decides what a cached entry is. +pub struct RedisSemanticCache { + connections: Arc>, + embedder: E, + codec: S, + inner: Arc, +} + +impl RedisSemanticCache { + pub fn new( + url: &str, + embedder: E, + codec: S, + config: RedisSemanticConfig, + ) -> Result { + Ok(Self { + connections: Arc::new(Connections::open(url, &RedisTopology::Standalone)?), + embedder, + codec, + inner: Arc::new(Inner::new(config, timestamp)), + }) + } +} + +impl RedisSemanticCache +where + E: Embedder, + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + pub fn with_connection( + connection: C, + embedder: E, + codec: S, + config: RedisSemanticConfig, + ) -> Self { Self { connections: Arc::new(Connections::fixed(connection)), embedder, - inner: Arc::new(Inner::new(config)), + codec, + inner: Arc::new(Inner::new(config, timestamp)), } } pub fn with_clock(self, clock: fn() -> f64) -> Self { + let config = RedisSemanticConfig { + index_name: self.index_name().to_owned(), + similarity_threshold: self.similarity_threshold(), + }; Self { - inner: Arc::new(Inner { - index_name: self.inner.index_name.clone(), - distance_threshold: self.inner.distance_threshold, - resolved_index: OnceLock::new(), - codec: self.inner.codec, - clock, - }), + inner: Arc::new(Inner::new(config, clock)), ..self } } @@ -243,7 +196,7 @@ impl RedisSemanticCache< } pub fn index_name(&self) -> &str { - &self.inner.index_name + self.inner.index.name() } pub fn similarity_threshold(&self) -> f32 { @@ -253,12 +206,25 @@ impl RedisSemanticCache< fn tag<'a>(key: &'a str, context: &'a SemanticCacheContext) -> &'a str { context.scope.as_deref().unwrap_or(key) } + + fn decode(&self, lookup: SemanticLookup>) -> Result, Error> { + Ok(SemanticLookup { + value: lookup + .value + .map(|bytes| self.codec.decode(&bytes)) + .transpose()?, + similarity: lookup.similarity, + }) + } } -impl BaseCache - for RedisSemanticCache +impl BaseCache for RedisSemanticCache +where + E: Embedder, + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, { - type Value = CacheEntry; + type Value = S::Value; type Context = SemanticCacheContext; fn get_ttl(&self, context: &Self::Context) -> Option { @@ -274,22 +240,18 @@ impl BaseCache let Some(prompt) = prompt_from_context(context) else { return Ok(()); }; + let response = self.codec.encode(&value)?; let vector = self.embedder.embed(&prompt, context.metadata.as_ref())?; - let tag = Self::tag(key, context).to_string(); + let tag = Self::tag(key, context); self.connections.execute(|connection| { self.inner - .store(connection, &tag, &value, &prompt, &vector, context.ttl) + .store(connection, tag, response, &prompt, &vector, context.ttl) }) } fn get_cache(&self, key: &str, context: &Self::Context) -> Result, Error> { - let Some(prompt) = prompt_from_context(context) else { - return Ok(None); - }; - let vector = self.embedder.embed(&prompt, context.metadata.as_ref())?; - let tag = Self::tag(key, context).to_string(); - self.connections - .execute(|connection| self.inner.lookup(connection, &tag, &vector)) + self.get_cache_with_similarity(key, context) + .map(|lookup| lookup.value) } async fn async_set_cache( @@ -301,14 +263,15 @@ impl BaseCache let Some(prompt) = prompt_from_context(&context) else { return Ok(()); }; + let response = self.codec.encode(&value)?; let vector = self .embedder .async_embed(&prompt, context.metadata.as_ref()) .await?; - let tag = Self::tag(key, &context).to_string(); + let tag = Self::tag(key, &context).to_owned(); let inner = Arc::clone(&self.inner); Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - inner.store(connection, &tag, &value, &prompt, &vector, context.ttl) + inner.store(connection, &tag, response, &prompt, &vector, context.ttl) }) .await } @@ -318,49 +281,54 @@ impl BaseCache key: &str, context: &Self::Context, ) -> Result, Error> { + self.async_get_cache_with_similarity(key, context) + .await + .map(|lookup| lookup.value) + } +} + +/// Python stamps a similarity of `0.0` when there is no prompt or no hit in the key's scope. +impl SemanticCache for RedisSemanticCache +where + E: Embedder, + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { let Some(prompt) = prompt_from_context(context) else { - return Ok(None); + return Ok(SemanticLookup::miss(Some(0.0))); + }; + let vector = self.embedder.embed(&prompt, context.metadata.as_ref())?; + let tag = Self::tag(key, context); + let lookup = self + .connections + .execute(|connection| self.inner.lookup(connection, tag, &vector))?; + self.decode(lookup) + } + + async fn async_get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + let Some(prompt) = prompt_from_context(context) else { + return Ok(SemanticLookup::miss(Some(0.0))); }; let vector = self .embedder .async_embed(&prompt, context.metadata.as_ref()) .await?; - let tag = Self::tag(key, context).to_string(); + let tag = Self::tag(key, context).to_owned(); let inner = Arc::clone(&self.inner); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { + let lookup = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { inner.lookup(connection, &tag, &vector) }) - .await - } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - match Connections::run_blocking(Arc::clone(&self.connections), |connection| { - Ok(match redis::cmd("PING").query::(connection) { - Ok(_) => CacheConnectionResult { - status: CacheConnectionStatus::Success, - message: "Redis cache connection test successful".into(), - error: None, - }, - Err(error) => CacheConnectionResult { - status: CacheConnectionStatus::Failed, - message: format!("Redis connection failed: {error}"), - error: Some(error.to_string()), - }, - }) - }) - .await - { - Ok(result) => Ok(result), - Err(error) => Ok(CacheConnectionResult { - status: CacheConnectionStatus::Failed, - message: format!("Redis connection failed: {error}"), - error: Some(error.to_string()), - }), - } + .await?; + self.decode(lookup) } } @@ -387,228 +355,46 @@ fn vector_buffer(vector: &[f32]) -> Vec { } fn escape_tag(value: &str) -> String { - value - .chars() - .flat_map(|ch| { - if matches!( - ch, - ',' | '.' - | '<' - | '>' - | '{' - | '}' - | '[' - | ']' - | '\\' - | '"' - | '\'' - | ':' - | ';' - | '!' - | '@' - | '#' - | '$' - | '%' - | '^' - | '&' - | '*' - | '(' - | ')' - | '-' - | '+' - | '=' - | '~' - | '|' - | '/' - | ' ' - | '?' - ) { - vec!['\\', ch] - } else { - vec![ch] - } - }) - .collect() -} - -fn create_index(connection: &mut ConnectionRef<'_>, name: &str, dims: usize) -> Result<(), Error> { - redis::cmd("FT.CREATE") - .arg(name) - .arg("ON") - .arg("HASH") - .arg("PREFIX") - .arg(1) - .arg(name) - .arg("SCORE") - .arg(1.0) - .arg("SCHEMA") - .arg("prompt") - .arg("TEXT") - .arg("WEIGHT") - .arg(1) - .arg("response") - .arg("TEXT") - .arg("WEIGHT") - .arg(1) - .arg("inserted_at") - .arg("NUMERIC") - .arg("updated_at") - .arg("NUMERIC") - .arg(VECTOR_FIELD) - .arg("VECTOR") - .arg("FLAT") - .arg(6) - .arg("TYPE") - .arg("FLOAT32") - .arg("DIM") - .arg(dims) - .arg("DISTANCE_METRIC") - .arg("COSINE") - .arg(CACHE_KEY_FIELD) - .arg("TAG") - .arg("SEPARATOR") - .arg(",") - .query::<()>(connection) - .map_err(|_| Error::Unavailable) -} - -fn index_compatible( - connection: &mut ConnectionRef<'_>, - name: &str, - dims: usize, -) -> Result, Error> { - let info = match redis::cmd("FT.INFO") - .arg(name) - .query::(connection) - { - Ok(info) => info, - Err(error) if unknown_index(&error) => return Ok(None), - Err(_) => return Err(Error::Unavailable), - }; - Ok(Some(schema_compatible(&info, dims))) -} - -fn unknown_index(error: &redis::RedisError) -> bool { - let message = error.to_string().to_lowercase(); - message.contains("unknown") && message.contains("index") -} - -fn schema_compatible(info: &redis::Value, dims: usize) -> bool { - let redis::Value::Array(entries) = info else { - return false; - }; - let attributes = entries - .as_chunks::<2>() - .0 - .iter() - .find(|pair| string_value(&pair[0]).as_deref() == Some("attributes")) - .map(|pair| &pair[1]); - let Some(redis::Value::Array(attributes)) = attributes else { - return false; - }; - let fields = attributes - .iter() - .map(|attribute| { - let redis::Value::Array(attribute) = attribute else { - return (None, None, None, None, None); - }; - let mut name = None; - let mut field_type = None; - let mut dim = None; - let mut data_type = None; - let mut distance_metric = None; - for pair in attribute.as_chunks::<2>().0 { - match string_value(&pair[0]).as_deref() { - Some("identifier") => name = string_value(&pair[1]), - Some("type") => field_type = string_value(&pair[1]), - Some("dim") => dim = number_value(&pair[1]), - Some("data_type") => data_type = string_value(&pair[1]), - Some("distance_metric") => distance_metric = string_value(&pair[1]), - _ => {} - } - } - (name, field_type, dim, data_type, distance_metric) - }) - .collect::>(); - let has_field = |name: &str, field_type: &str| { - fields - .iter() - .any(|(n, t, ..)| n.as_deref() == Some(name) && t.as_deref() == Some(field_type)) - }; - has_field("prompt", "TEXT") - && has_field("response", "TEXT") - && has_field("inserted_at", "NUMERIC") - && has_field("updated_at", "NUMERIC") - && has_field(CACHE_KEY_FIELD, "TAG") - && fields.iter().any(|(n, t, d, data, metric)| { - n.as_deref() == Some(VECTOR_FIELD) - && t.as_deref() == Some("VECTOR") - && *d == Some(dims as f64) - && data - .as_deref() - .is_some_and(|data| data.eq_ignore_ascii_case("float32")) - && metric - .as_deref() - .is_some_and(|metric| metric.eq_ignore_ascii_case("cosine")) - }) -} - -fn string_value(value: &redis::Value) -> Option { - match value { - redis::Value::BulkString(bytes) => String::from_utf8(bytes.clone()).ok(), - redis::Value::SimpleString(text) => Some(text.clone()), - redis::Value::VerbatimString { text, .. } => Some(text.clone()), - _ => None, - } -} - -fn number_value(value: &redis::Value) -> Option { - match value { - redis::Value::Int(number) => Some(*number as f64), - redis::Value::Double(number) => Some(*number), - _ => string_value(value).and_then(|text| text.parse().ok()), - } -} - -fn first_document(result: &redis::Value) -> Option<&[redis::Value]> { - let redis::Value::Array(items) = result else { - return None; - }; - let [count, _document_id, fields, ..] = items.as_slice() else { - return None; - }; - if !matches!(count, redis::Value::Int(count) if *count > 0) { - return None; - } - match fields { - redis::Value::Array(fields) => Some(fields.as_slice()), - _ => None, - } -} - -fn field_value<'a>(fields: &'a [redis::Value], name: &str) -> Option<&'a redis::Value> { - fields - .as_chunks::<2>() - .0 - .iter() - .find(|pair| string_value(&pair[0]).as_deref() == Some(name)) - .map(|pair| &pair[1]) -} - -fn string_field(fields: &[redis::Value], name: &str) -> Option { - field_value(fields, name).and_then(string_value) -} - -fn number_field(fields: &[redis::Value], name: &str) -> Option { - field_value(fields, name).and_then(number_value) -} - -fn bytes_field(fields: &[redis::Value], name: &str) -> Option> { - match field_value(fields, name)? { - redis::Value::BulkString(bytes) => Some(bytes.clone()), - redis::Value::SimpleString(text) => Some(text.clone().into_bytes()), - _ => None, + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + if matches!( + ch, + ',' | '.' + | '<' + | '>' + | '{' + | '}' + | '[' + | ']' + | '\\' + | '"' + | '\'' + | ':' + | ';' + | '!' + | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '*' + | '(' + | ')' + | '-' + | '+' + | '=' + | '~' + | '|' + | '/' + | ' ' + | '?' + ) { + escaped.push('\\'); + } + escaped.push(ch); } + escaped } fn ttl_seconds(ttl: Duration) -> u64 { diff --git a/litellm-rust/crates/cache-redis-semantic/src/config.rs b/litellm-rust/crates/cache-redis-semantic/src/config.rs new file mode 100644 index 00000000000..6b810628be7 --- /dev/null +++ b/litellm-rust/crates/cache-redis-semantic/src/config.rs @@ -0,0 +1,8 @@ +/// `RedisSemanticCache.DEFAULT_REDIS_INDEX_NAME`. +pub const DEFAULT_INDEX_NAME: &str = "litellm_semantic_cache_index"; + +#[derive(Clone, Debug)] +pub struct RedisSemanticConfig { + pub index_name: String, + pub similarity_threshold: f32, +} diff --git a/litellm-rust/crates/cache-redis-semantic/src/index.rs b/litellm-rust/crates/cache-redis-semantic/src/index.rs new file mode 100644 index 00000000000..c141e47003a --- /dev/null +++ b/litellm-rust/crates/cache-redis-semantic/src/index.rs @@ -0,0 +1,205 @@ +use std::sync::OnceLock; + +use litellm_cache::Error; +use litellm_cache_redis::connection::ConnectionRef; + +use crate::reply::{number_value, string_value}; + +pub(crate) const CACHE_KEY_FIELD: &str = "litellm_cache_key"; +pub(crate) const VECTOR_FIELD: &str = "prompt_vector"; + +/// The redisvl `SemanticCache` index, resolved once per cache: the configured name when its +/// schema fits, else `_isolated`, recreated when that one is stale too. +pub(crate) struct Index { + name: String, + resolved: OnceLock, +} + +impl Index { + pub(crate) fn new(name: String) -> Self { + Self { + name, + resolved: OnceLock::new(), + } + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(crate) fn ensure( + &self, + connection: &mut ConnectionRef<'_>, + dims: usize, + ) -> Result { + if let Some(name) = self.resolved.get() { + return Ok(name.clone()); + } + let name = match index_compatible(connection, &self.name, dims)? { + Some(true) => self.name.clone(), + Some(false) => self.isolated(connection, dims)?, + None => match create_index(connection, &self.name, dims) { + Ok(()) => self.name.clone(), + Err(_) => match index_compatible(connection, &self.name, dims)? { + Some(true) => self.name.clone(), + Some(false) => self.isolated(connection, dims)?, + None => return Err(Error::Unavailable), + }, + }, + }; + let _ = self.resolved.set(name.clone()); + Ok(name) + } + + fn isolated(&self, connection: &mut ConnectionRef<'_>, dims: usize) -> Result { + let name = format!("{}_isolated", self.name); + match index_compatible(connection, &name, dims)? { + Some(true) => Ok(name), + Some(false) => { + redis::cmd("FT.DROPINDEX") + .arg(&name) + .query::<()>(connection) + .map_err(|_| Error::Unavailable)?; + create_index(connection, &name, dims)?; + Ok(name) + } + None => { + create_index(connection, &name, dims)?; + Ok(name) + } + } + } +} + +fn create_index(connection: &mut ConnectionRef<'_>, name: &str, dims: usize) -> Result<(), Error> { + redis::cmd("FT.CREATE") + .arg(name) + .arg("ON") + .arg("HASH") + .arg("PREFIX") + .arg(1) + .arg(name) + .arg("SCORE") + .arg(1.0) + .arg("SCHEMA") + .arg("prompt") + .arg("TEXT") + .arg("WEIGHT") + .arg(1) + .arg("response") + .arg("TEXT") + .arg("WEIGHT") + .arg(1) + .arg("inserted_at") + .arg("NUMERIC") + .arg("updated_at") + .arg("NUMERIC") + .arg(VECTOR_FIELD) + .arg("VECTOR") + .arg("FLAT") + .arg(6) + .arg("TYPE") + .arg("FLOAT32") + .arg("DIM") + .arg(dims) + .arg("DISTANCE_METRIC") + .arg("COSINE") + .arg(CACHE_KEY_FIELD) + .arg("TAG") + .arg("SEPARATOR") + .arg(",") + .query::<()>(connection) + .map_err(|_| Error::Unavailable) +} + +fn index_compatible( + connection: &mut ConnectionRef<'_>, + name: &str, + dims: usize, +) -> Result, Error> { + let info = match redis::cmd("FT.INFO") + .arg(name) + .query::(connection) + { + Ok(info) => info, + Err(error) if unknown_index(&error) => return Ok(None), + Err(_) => return Err(Error::Unavailable), + }; + Ok(Some(schema_compatible(&info, dims))) +} + +fn unknown_index(error: &redis::RedisError) -> bool { + let message = error.to_string().to_lowercase(); + message.contains("unknown") && message.contains("index") +} + +struct Attribute { + name: Option, + field_type: Option, + dim: Option, + data_type: Option, + distance_metric: Option, +} + +fn attribute(value: &redis::Value) -> Option { + let redis::Value::Array(pairs) = value else { + return None; + }; + let mut attribute = Attribute { + name: None, + field_type: None, + dim: None, + data_type: None, + distance_metric: None, + }; + for pair in pairs.as_chunks::<2>().0 { + match string_value(&pair[0]).as_deref() { + Some("identifier") => attribute.name = string_value(&pair[1]), + Some("type") => attribute.field_type = string_value(&pair[1]), + Some("dim") => attribute.dim = number_value(&pair[1]), + Some("data_type") => attribute.data_type = string_value(&pair[1]), + Some("distance_metric") => attribute.distance_metric = string_value(&pair[1]), + _ => {} + } + } + Some(attribute) +} + +fn schema_compatible(info: &redis::Value, dims: usize) -> bool { + let redis::Value::Array(entries) = info else { + return false; + }; + let attributes = entries + .as_chunks::<2>() + .0 + .iter() + .find(|pair| string_value(&pair[0]).as_deref() == Some("attributes")) + .map(|pair| &pair[1]); + let Some(redis::Value::Array(attributes)) = attributes else { + return false; + }; + let fields = attributes.iter().filter_map(attribute).collect::>(); + let has_field = |name: &str, field_type: &str| { + fields.iter().any(|field| { + field.name.as_deref() == Some(name) && field.field_type.as_deref() == Some(field_type) + }) + }; + has_field("prompt", "TEXT") + && has_field("response", "TEXT") + && has_field("inserted_at", "NUMERIC") + && has_field("updated_at", "NUMERIC") + && has_field(CACHE_KEY_FIELD, "TAG") + && fields.iter().any(|field| { + field.name.as_deref() == Some(VECTOR_FIELD) + && field.field_type.as_deref() == Some("VECTOR") + && field.dim == Some(dims as f64) + && field + .data_type + .as_deref() + .is_some_and(|data| data.eq_ignore_ascii_case("float32")) + && field + .distance_metric + .as_deref() + .is_some_and(|metric| metric.eq_ignore_ascii_case("cosine")) + }) +} diff --git a/litellm-rust/crates/cache-redis-semantic/src/lib.rs b/litellm-rust/crates/cache-redis-semantic/src/lib.rs index 51d0b4ba5f3..251323df6cf 100644 --- a/litellm-rust/crates/cache-redis-semantic/src/lib.rs +++ b/litellm-rust/crates/cache-redis-semantic/src/lib.rs @@ -1,5 +1,7 @@ mod cache; -mod prompt; +mod config; +mod index; +mod reply; -pub use cache::{Embedder, RedisSemanticCache, RedisSemanticConfig}; -pub use prompt::prompt_from_context; +pub use cache::RedisSemanticCache; +pub use config::{DEFAULT_INDEX_NAME, RedisSemanticConfig}; diff --git a/litellm-rust/crates/cache-redis-semantic/src/prompt.rs b/litellm-rust/crates/cache-redis-semantic/src/prompt.rs deleted file mode 100644 index b9c38e98d77..00000000000 --- a/litellm-rust/crates/cache-redis-semantic/src/prompt.rs +++ /dev/null @@ -1,97 +0,0 @@ -use litellm_cache::SemanticCacheContext; -use serde_json::Value; - -pub fn prompt_from_context(context: &SemanticCacheContext) -> Option { - if let Some(messages) = context.messages.as_ref().and_then(Value::as_array) - && !messages.is_empty() - { - return Some(messages_text(messages)); - } - let input = context.input.as_ref()?; - let mut parts = Vec::new(); - collect_input_text(input, &mut parts); - let prompt = parts.join("\n").trim().to_string(); - (!prompt.is_empty()).then_some(prompt) -} - -fn messages_text(messages: &[Value]) -> String { - let mut text = String::new(); - for message in messages { - let Some(message) = message.as_object() else { - continue; - }; - match message.get("content") { - Some(Value::String(content)) => text.push_str(content), - Some(Value::Array(parts)) => { - for part in parts { - if let Some(text_content) = part.get("text").and_then(Value::as_str) { - text.push_str(text_content); - } - } - } - _ => {} - } - text.push_str(&search_results_text(message.get("search_results"))); - } - text -} - -fn search_results_text(search_results: Option<&Value>) -> String { - let Some(Value::Array(results)) = search_results else { - return String::new(); - }; - let mut text = String::new(); - for result in results { - let Some(result) = result.as_object() else { - continue; - }; - for key in ["source", "title"] { - if let Some(value) = result.get(key).and_then(Value::as_str) { - text.push_str(value); - } - } - if let Some(Value::Array(content)) = result.get("content") { - for block in content { - if let Some(value) = block.get("text").and_then(Value::as_str) { - text.push_str(value); - } - } - } - if let Some(citations) = result.get("citations") { - text.push_str(&citations.to_string()); - } - } - text -} - -fn collect_input_text(value: &Value, parts: &mut Vec) { - match value { - Value::String(text) => { - let trimmed = text.trim(); - if !trimmed.is_empty() { - parts.push(trimmed.to_string()); - } - } - Value::Array(items) => { - for item in items { - collect_input_text(item, parts); - } - } - Value::Object(map) => { - if let Some(content) = map.get("content").filter(|content| !content.is_null()) { - collect_input_text(content, parts); - return; - } - for key in ["text", "output", "input_text", "output_text"] { - if let Some(Value::String(text)) = map.get(key) { - let trimmed = text.trim(); - if !trimmed.is_empty() { - parts.push(trimmed.to_string()); - return; - } - } - } - } - _ => {} - } -} diff --git a/litellm-rust/crates/cache-redis-semantic/src/reply.rs b/litellm-rust/crates/cache-redis-semantic/src/reply.rs new file mode 100644 index 00000000000..24cbd573cde --- /dev/null +++ b/litellm-rust/crates/cache-redis-semantic/src/reply.rs @@ -0,0 +1,57 @@ +pub(crate) fn string_value(value: &redis::Value) -> Option { + match value { + redis::Value::BulkString(bytes) => String::from_utf8(bytes.clone()).ok(), + redis::Value::SimpleString(text) => Some(text.clone()), + redis::Value::VerbatimString { text, .. } => Some(text.clone()), + _ => None, + } +} + +pub(crate) fn number_value(value: &redis::Value) -> Option { + match value { + redis::Value::Int(number) => Some(*number as f64), + redis::Value::Double(number) => Some(*number), + _ => string_value(value).and_then(|text| text.parse().ok()), + } +} + +pub(crate) fn first_document(result: &redis::Value) -> Option<&[redis::Value]> { + let redis::Value::Array(items) = result else { + return None; + }; + let [count, _document_id, fields, ..] = items.as_slice() else { + return None; + }; + if !matches!(count, redis::Value::Int(count) if *count > 0) { + return None; + } + match fields { + redis::Value::Array(fields) => Some(fields.as_slice()), + _ => None, + } +} + +fn field_value<'a>(fields: &'a [redis::Value], name: &str) -> Option<&'a redis::Value> { + fields + .as_chunks::<2>() + .0 + .iter() + .find(|pair| string_value(&pair[0]).as_deref() == Some(name)) + .map(|pair| &pair[1]) +} + +pub(crate) fn string_field(fields: &[redis::Value], name: &str) -> Option { + field_value(fields, name).and_then(string_value) +} + +pub(crate) fn number_field(fields: &[redis::Value], name: &str) -> Option { + field_value(fields, name).and_then(number_value) +} + +pub(crate) fn bytes_field(fields: &[redis::Value], name: &str) -> Option> { + match field_value(fields, name)? { + redis::Value::BulkString(bytes) => Some(bytes.clone()), + redis::Value::SimpleString(text) => Some(text.clone().into_bytes()), + _ => None, + } +} diff --git a/litellm-rust/crates/cache-redis-semantic/tests/cache.rs b/litellm-rust/crates/cache-redis-semantic/tests/cache.rs index 233b87ec52f..fa4ac00e767 100644 --- a/litellm-rust/crates/cache-redis-semantic/tests/cache.rs +++ b/litellm-rust/crates/cache-redis-semantic/tests/cache.rs @@ -1,55 +1,26 @@ -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, - time::Duration, -}; +mod support; -use litellm_cache::{BaseCache, CacheCodec, Error, SemanticCacheContext}; -use litellm_cache_redis_semantic::{Embedder, RedisSemanticCache, RedisSemanticConfig}; -use litellm_cache_response::{CacheEntry, ResponseCacheCodec}; +use std::time::Duration; + +use litellm_cache::{ + BaseCache, Error, JsonCodec, SemanticCacheContext, + semantic::{SemanticCache, SemanticLookup}, +}; +use litellm_cache_redis_semantic::{DEFAULT_INDEX_NAME, RedisSemanticCache, RedisSemanticConfig}; use redis_test::{MockCmd, MockRedisConnection}; +use rstest::{fixture, rstest}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +use support::FakeEmbedder; -const INDEX: &str = "litellm_semantic_cache_index"; +const INDEX: &str = DEFAULT_INDEX_NAME; +const PROMPT: &str = "hello prompt"; +const CLOCK: fn() -> f64 = || 1700000000.5; +const VECTOR: [f32; 3] = [0.1, 0.2, 0.3]; -struct FakeEmbedder { - vectors: HashMap>, - calls: Arc>>, -} - -impl FakeEmbedder { - fn new(vectors: &[(&str, &[f32])]) -> (Self, Arc>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - ( - Self { - vectors: vectors - .iter() - .map(|(prompt, vector)| (prompt.to_string(), vector.to_vec())) - .collect(), - calls: Arc::clone(&calls), - }, - calls, - ) - } -} - -impl Embedder for FakeEmbedder { - fn embed(&self, prompt: &str, _: Option<&Value>) -> Result, Error> { - self.calls.lock().unwrap().push(prompt.to_string()); - - Ok(self - .vectors - .get(prompt) - .cloned() - .unwrap_or_else(|| vec![0.1, 0.2, 0.3])) - } - - async fn async_embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { - self.embed(prompt, metadata) - } -} +type MockCache = RedisSemanticCache, MockRedisConnection>; +#[fixture] fn config() -> RedisSemanticConfig { RedisSemanticConfig { index_name: INDEX.into(), @@ -57,6 +28,16 @@ fn config() -> RedisSemanticConfig { } } +#[fixture] +fn entry() -> Value { + json!({"timestamp": 1.0, "response": {"answer": "yes"}}) +} + +#[fixture] +fn context() -> SemanticCacheContext { + messages_context(vec![json!({"role": "user", "content": PROMPT})]) +} + fn messages_context(messages: Vec) -> SemanticCacheContext { SemanticCacheContext { messages: Some(Value::Array(messages)), @@ -64,15 +45,18 @@ fn messages_context(messages: Vec) -> SemanticCacheContext { } } -fn entry() -> CacheEntry { - CacheEntry { - timestamp: Some(1.0), - response: json!({"answer": "yes"}), - } +fn cache(commands: Vec, embedder: FakeEmbedder) -> MockCache { + RedisSemanticCache::with_connection( + MockRedisConnection::new(commands).assert_all_commands_consumed(), + embedder, + JsonCodec::new(), + config(), + ) + .with_clock(CLOCK) } -fn encoded(entry: &CacheEntry) -> Vec { - ResponseCacheCodec.encode(entry).unwrap() +fn encoded(value: &Value) -> Vec { + serde_json::to_vec(value).unwrap() } fn vector_bytes(vector: &[f32]) -> Vec { @@ -98,6 +82,17 @@ fn unknown_index_error() -> redis::RedisError { redis::RedisError::from((redis::ErrorKind::Extension, "Unknown index name")) } +fn info_missing(index: &str) -> MockCmd { + MockCmd::new( + redis::cmd("FT.INFO").arg(index), + Err::(unknown_index_error()), + ) +} + +fn info(index: &str, value: redis::Value) -> MockCmd { + MockCmd::new(redis::cmd("FT.INFO").arg(index), Ok(value)) +} + fn attribute(name: &str, field_type: &str, extra: Vec) -> redis::Value { let mut parts = vec![ s("identifier"), @@ -137,10 +132,6 @@ fn vector_attribute_with(dims: i64, data_type: &str, distance_metric: &str) -> r ) } -fn vector_attribute(dims: i64) -> redis::Value { - vector_attribute_with(dims, "FLOAT32", "COSINE") -} - fn info_with_vector(vector: redis::Value) -> redis::Value { index_info(vec![ attribute("prompt", "TEXT", vec![]), @@ -153,7 +144,7 @@ fn info_with_vector(vector: redis::Value) -> redis::Value { } fn compatible_info(dims: i64) -> redis::Value { - info_with_vector(vector_attribute(dims)) + info_with_vector(vector_attribute_with(dims, "FLOAT32", "COSINE")) } fn unscoped_info(dims: i64) -> redis::Value { @@ -162,10 +153,14 @@ fn unscoped_info(dims: i64) -> redis::Value { attribute("response", "TEXT", vec![]), attribute("inserted_at", "NUMERIC", vec![]), attribute("updated_at", "NUMERIC", vec![]), - vector_attribute(dims), + vector_attribute_with(dims, "FLOAT32", "COSINE"), ]) } +fn create_index(name: &str, dims: usize) -> MockCmd { + MockCmd::new(create_index_command(name, dims), Ok("OK")) +} + fn create_index_command(name: &str, dims: usize) -> redis::Cmd { let mut command = redis::cmd("FT.CREATE"); command @@ -207,7 +202,34 @@ fn create_index_command(name: &str, dims: usize) -> redis::Cmd { command } -fn search_command(index: &str, tag: &str, vector: &[f32]) -> redis::Cmd { +fn hset(index: &str, prompt: &str, tag: &str, vector: &[f32], value: &Value) -> MockCmd { + MockCmd::new( + redis::cmd("HSET") + .arg(format!("{index}:{}", entry_id(prompt, tag))) + .arg("entry_id") + .arg(entry_id(prompt, tag)) + .arg("prompt") + .arg(prompt) + .arg("response") + .arg(encoded(value)) + .arg("prompt_vector") + .arg(vector_bytes(vector)) + .arg("inserted_at") + .arg("1700000000.5") + .arg("updated_at") + .arg("1700000000.5") + .arg("litellm_cache_key") + .arg(tag), + Ok(7), + ) +} + +fn search( + index: &str, + tag: &str, + vector: &[f32], + reply: redis::RedisResult, +) -> MockCmd { let mut command = redis::cmd("FT.SEARCH"); command .arg(index) @@ -236,33 +258,29 @@ fn search_command(index: &str, tag: &str, vector: &[f32]) -> redis::Cmd { .arg(2) .arg("vector") .arg(vector_bytes(vector)); - command + MockCmd::new(command, reply) } -fn hit_fields(tag: &str, distance: &str, response: Vec) -> redis::Value { - redis::Value::Array(vec![ - s("entry_id"), - s("stored-id"), - s("prompt"), - s("hello prompt"), - s("response"), - redis::Value::BulkString(response), - s("inserted_at"), - s("1700000000.5"), - s("updated_at"), - s("1700000000.5"), - s("litellm_cache_key"), - s(tag), - s("vector_distance"), - s(distance), - ]) -} - -fn search_result(fields: redis::Value) -> redis::Value { +fn hit(tag: &str, distance: &str, response: Vec) -> redis::Value { redis::Value::Array(vec![ redis::Value::Int(1), s("litellm_semantic_cache_index:stored-id"), - fields, + redis::Value::Array(vec![ + s("entry_id"), + s("stored-id"), + s("prompt"), + s(PROMPT), + s("response"), + redis::Value::BulkString(response), + s("inserted_at"), + s("1700000000.5"), + s("updated_at"), + s("1700000000.5"), + s("litellm_cache_key"), + s(tag), + s("vector_distance"), + s(distance), + ]), ]) } @@ -270,685 +288,452 @@ fn empty_result() -> redis::Value { redis::Value::Array(vec![redis::Value::Int(0)]) } -#[test] -fn store_creates_index_and_writes_hash_with_expire() { - let vector = vec![0.1f32, 0.2, 0.3]; - let prompt = "hello prompt"; - let tag = "key1"; - let hash_key = format!("{INDEX}:{}", entry_id(prompt, tag)); - let value = entry(); - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("FT.INFO").arg(INDEX), - Err::(unknown_index_error()), - ), - MockCmd::new(create_index_command(INDEX, 3), Ok("OK")), - MockCmd::new( - redis::cmd("HSET") - .arg(&hash_key) - .arg("entry_id") - .arg(entry_id(prompt, tag)) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&value)) - .arg("prompt_vector") - .arg(vector_bytes(&vector)) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg(tag), - Ok(7), - ), - MockCmd::new(redis::cmd("EXPIRE").arg(&hash_key).arg(5), Ok(1)), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[(prompt, &vector)]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); - - let context = SemanticCacheContext { - ttl: Some(Duration::from_secs(5)), - ..messages_context(vec![json!({"role": "user", "content": prompt})]) +#[rstest] +#[case::creates_index_and_expires(false, Some(Duration::from_secs(5)), Some(5))] +#[case::existing_index_without_ttl(true, None, None)] +#[case::fractional_ttl_rounds_up(true, Some(Duration::from_millis(1500)), Some(2))] +fn store_writes_the_redisvl_hash( + #[case] index_exists: bool, + #[case] ttl: Option, + #[case] expire: Option, + entry: Value, + context: SemanticCacheContext, +) { + let hash_key = format!("{INDEX}:{}", entry_id(PROMPT, "key1")); + let mut commands = if index_exists { + vec![info(INDEX, compatible_info(3))] + } else { + vec![info_missing(INDEX), create_index(INDEX, 3)] }; - cache.set_cache(tag, value, &context).unwrap(); -} - -#[test] -fn store_without_ttl_skips_expire() { - let prompt = "hello prompt"; - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - redis::cmd("HSET") - .arg(format!("{INDEX}:{}", entry_id(prompt, "key1"))) - .arg("entry_id") - .arg(entry_id(prompt, "key1")) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&entry())) - .arg("prompt_vector") - .arg(vector_bytes(&[0.1f32, 0.2, 0.3])) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg("key1"), - Ok(7), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); + commands.push(hset(INDEX, PROMPT, "key1", &VECTOR, &entry)); + commands.extend( + expire.map(|seconds| MockCmd::new(redis::cmd("EXPIRE").arg(&hash_key).arg(seconds), Ok(1))), + ); + let cache = cache(commands, FakeEmbedder::new(&[])); cache - .set_cache( - "key1", - entry(), - &messages_context(vec![json!({"role": "user", "content": prompt})]), - ) + .set_cache("key1", entry, &SemanticCacheContext { ttl, ..context }) .unwrap(); } -#[test] -fn lookup_returns_hit_below_distance_threshold() { - let vector = vec![0.1f32, 0.2, 0.3]; - let value = entry(); - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - search_command(INDEX, "key1", &vector), - Ok(search_result(hit_fields("key1", "0.05", encoded(&value)))), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()); - - let hit = cache - .get_cache( - "key1", - &messages_context(vec![json!({"role": "user", "content": "hello prompt"})]), - ) - .unwrap(); - assert_eq!(hit, Some(value)); -} - -#[test] -fn lookup_misses_above_distance_threshold_and_on_tag_mismatch() { - let vector = vec![0.1f32, 0.2, 0.3]; - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - search_command(INDEX, "key1", &vector), - Ok(search_result(hit_fields("key1", "0.5", encoded(&entry())))), - ), - MockCmd::new( - search_command(INDEX, "key1", &vector), - Ok(search_result(hit_fields( - "other", - "0.05", - encoded(&entry()), - ))), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()); - let context = messages_context(vec![json!({"role": "user", "content": "hello prompt"})]); - - assert_eq!(cache.get_cache("key1", &context).unwrap(), None); - assert_eq!(cache.get_cache("key1", &context).unwrap(), None); -} - -#[test] -fn lookup_returns_invalid_entry_on_malformed_response() { - let vector = vec![0.1f32, 0.2, 0.3]; - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - search_command(INDEX, "key1", &vector), - Ok(search_result(hit_fields( +#[rstest] +#[case::below_distance_threshold("key1", "0.05", None, Ok(Some(entry())))] +#[case::above_distance_threshold("key1", "0.5", None, Ok(None))] +#[case::other_cache_key("other", "0.05", None, Ok(None))] +#[case::malformed_response("key1", "0.05", Some(b"not json!".as_slice()), Err(Error::InvalidEntry))] +fn lookup_applies_threshold_scope_and_codec( + #[case] stored_tag: &str, + #[case] distance: &str, + #[case] response: Option<&[u8]>, + #[case] expected: Result, Error>, + entry: Value, + context: SemanticCacheContext, +) { + let response = response.map_or_else(|| encoded(&entry), <[u8]>::to_vec); + let cache = cache( + vec![ + info(INDEX, compatible_info(3)), + search( + INDEX, "key1", - "0.05", - b"not json!".to_vec(), - ))), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()); - - assert_eq!( - cache - .get_cache( - "key1", - &messages_context(vec![json!({"role": "user", "content": "hello prompt"})]) - ) - .unwrap_err(), - Error::InvalidEntry + &VECTOR, + Ok(hit(stored_tag, distance, response)), + ), + ], + FakeEmbedder::new(&[]), ); + + assert_eq!(cache.get_cache("key1", &context), expected); } -#[test] -fn missing_prompt_is_noop_and_never_embeds() { - let connection = MockRedisConnection::new(Vec::::new()).assert_all_commands_consumed(); - let (embedder, calls) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()); +#[rstest] +#[case::hit(context(), Some(hit("key1", "0.05", encoded(&entry()))), Some(entry()), Some(1.0 - 0.05))] +#[case::beyond_distance_threshold(context(), Some(hit("key1", "0.5", encoded(&entry()))), None, Some(0.0))] +#[case::no_results(context(), Some(empty_result()), None, Some(0.0))] +#[case::other_cache_key(context(), Some(hit("other", "0.05", encoded(&entry()))), None, Some(0.0))] +#[case::no_prompt(SemanticCacheContext::default(), None, None, Some(0.0))] +#[tokio::test] +async fn lookup_reports_python_semantic_similarity( + #[case] context: SemanticCacheContext, + #[case] reply: Option, + #[case] value: Option, + #[case] similarity: Option, + #[values(false, true)] use_async: bool, +) { + let commands = reply.map_or_else(Vec::new, |reply| { + vec![ + info(INDEX, compatible_info(3)), + search(INDEX, "key1", &VECTOR, Ok(reply)), + ] + }); + let cache = cache(commands, FakeEmbedder::new(&[])); + let lookup = if use_async { + cache + .async_get_cache_with_similarity("key1", &context) + .await + } else { + cache.get_cache_with_similarity("key1", &context) + }; + + assert_eq!(lookup, Ok(SemanticLookup { value, similarity })); +} + +#[rstest] +#[tokio::test] +async fn missing_prompt_is_a_noop_that_never_embeds(entry: Value) { + let embedder = FakeEmbedder::new(&[]); + let calls = embedder.calls.clone(); + let cache = cache(Vec::new(), embedder); let context = SemanticCacheContext::default(); - cache.set_cache("key1", entry(), &context).unwrap(); + + cache.set_cache("key1", entry.clone(), &context).unwrap(); assert_eq!(cache.get_cache("key1", &context).unwrap(), None); + cache + .async_set_cache("key1", entry, context.clone()) + .await + .unwrap(); + assert_eq!(cache.async_get_cache("key1", &context).await.unwrap(), None); assert!(calls.lock().unwrap().is_empty()); } -#[test] -fn scope_overrides_key_as_filter_tag() { - let vector = vec![0.1f32, 0.2, 0.3]; - let prompt = "hello prompt"; - let value = entry(); - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - redis::cmd("HSET") - .arg(format!("{INDEX}:{}", entry_id(prompt, "scope-a"))) - .arg("entry_id") - .arg(entry_id(prompt, "scope-a")) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&value)) - .arg("prompt_vector") - .arg(vector_bytes(&vector)) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg("scope-a"), - Ok(7), - ), - MockCmd::new( - search_command(INDEX, "scope\\-a", &vector), - Ok(search_result(hit_fields( - "scope-a", - "0.05", - encoded(&value), - ))), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); +#[rstest] +fn scope_overrides_key_as_filter_tag(entry: Value, context: SemanticCacheContext) { + let cache = cache( + vec![ + info(INDEX, compatible_info(3)), + hset(INDEX, PROMPT, "scope-a", &VECTOR, &entry), + search( + INDEX, + "scope\\-a", + &VECTOR, + Ok(hit("scope-a", "0.05", encoded(&entry))), + ), + ], + FakeEmbedder::new(&[]), + ); let context = SemanticCacheContext { scope: Some("scope-a".into()), - ..messages_context(vec![json!({"role": "user", "content": prompt})]) + ..context }; - cache.set_cache("key1", value.clone(), &context).unwrap(); - assert_eq!(cache.get_cache("key1", &context).unwrap(), Some(value)); + cache.set_cache("key1", entry.clone(), &context).unwrap(); + assert_eq!(cache.get_cache("key1", &context).unwrap(), Some(entry)); } -#[test] -fn incompatible_schema_falls_back_to_isolated_index() { - let prompt = "hello prompt"; - let tag = "key1"; +#[rstest] +#[case::unscoped_schema(unscoped_info(3))] +#[case::wrong_distance_metric(info_with_vector(vector_attribute_with(3, "FLOAT32", "L2")))] +#[case::wrong_data_type(info_with_vector(vector_attribute_with(3, "FLOAT64", "COSINE")))] +fn incompatible_schema_falls_back_to_isolated_index( + #[case] base_info: redis::Value, + entry: Value, + context: SemanticCacheContext, +) { let isolated = format!("{INDEX}_isolated"); - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(unscoped_info(3))), - MockCmd::new( - redis::cmd("FT.INFO").arg(&isolated), - Err::(unknown_index_error()), - ), - MockCmd::new(create_index_command(&isolated, 3), Ok("OK")), - MockCmd::new( - redis::cmd("HSET") - .arg(format!("{isolated}:{}", entry_id(prompt, tag))) - .arg("entry_id") - .arg(entry_id(prompt, tag)) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&entry())) - .arg("prompt_vector") - .arg(vector_bytes(&[0.1f32, 0.2, 0.3])) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg(tag), - Ok(7), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); - - cache - .set_cache( - tag, - entry(), - &messages_context(vec![json!({"role": "user", "content": prompt})]), - ) - .unwrap(); -} - -#[test] -fn create_index_race_rechecks_schema_and_stores() { - let prompt = "hello prompt"; - let tag = "key1"; - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("FT.INFO").arg(INDEX), - Err::(unknown_index_error()), - ), - MockCmd::new( - create_index_command(INDEX, 3), - Err::<&str, _>(redis::RedisError::from(( - redis::ErrorKind::Extension, - "Index already exists", - ))), - ), - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - redis::cmd("HSET") - .arg(format!("{INDEX}:{}", entry_id(prompt, tag))) - .arg("entry_id") - .arg(entry_id(prompt, tag)) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&entry())) - .arg("prompt_vector") - .arg(vector_bytes(&[0.1f32, 0.2, 0.3])) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg(tag), - Ok(7), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); - - cache - .set_cache( - tag, - entry(), - &messages_context(vec![json!({"role": "user", "content": prompt})]), - ) - .unwrap(); -} - -#[test] -fn wrong_distance_metric_falls_back_to_isolated_index() { - let prompt = "hello prompt"; - let tag = "key1"; - let isolated = format!("{INDEX}_isolated"); - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("FT.INFO").arg(INDEX), - Ok(info_with_vector(vector_attribute_with(3, "FLOAT32", "L2"))), - ), - MockCmd::new( - redis::cmd("FT.INFO").arg(&isolated), - Err::(unknown_index_error()), - ), - MockCmd::new(create_index_command(&isolated, 3), Ok("OK")), - MockCmd::new( - redis::cmd("HSET") - .arg(format!("{isolated}:{}", entry_id(prompt, tag))) - .arg("entry_id") - .arg(entry_id(prompt, tag)) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&entry())) - .arg("prompt_vector") - .arg(vector_bytes(&[0.1f32, 0.2, 0.3])) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg(tag), - Ok(7), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); - - cache - .set_cache( - tag, - entry(), - &messages_context(vec![json!({"role": "user", "content": prompt})]), - ) - .unwrap(); -} - -#[test] -fn tag_special_characters_are_escaped_in_search_filter() { - let vector = vec![0.1f32, 0.2, 0.3]; - let tag = "a:b, c|d"; - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - search_command(INDEX, "a\\:b\\,\\ c\\|d", &vector), - Ok(empty_result()), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()); - - assert_eq!( - cache - .get_cache( - tag, - &messages_context(vec![json!({"role": "user", "content": "hello prompt"})]) - ) - .unwrap(), - None - ); -} - -#[test] -fn prompt_extraction_matches_python_message_and_input_shapes() { - let vector = vec![0.1f32, 0.2, 0.3]; - let lookups = 5; - let mut commands = vec![MockCmd::new( - redis::cmd("FT.INFO").arg(INDEX), - Ok(compatible_info(3)), - )]; - for _ in 0..lookups { - commands.push(MockCmd::new( - search_command(INDEX, "key1", &vector), - Ok(empty_result()), - )); - } - let connection = MockRedisConnection::new(commands).assert_all_commands_consumed(); - let (embedder, calls) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()); - - cache - .get_cache( - "key1", - &messages_context(vec![ - json!({"role": "user", "content": [{"type": "text", "text": "first"}, {"type": "text", "text": "second"}]}), - json!({"role": "assistant", "content": "reply"}), - ]), - ) - .unwrap(); - cache - .get_cache( - "key1", - &SemanticCacheContext { - input: Some(json!(" plain input ")), - ..Default::default() - }, - ) - .unwrap(); - cache - .get_cache( - "key1", - &SemanticCacheContext { - input: Some( - json!([{"content": [{"type": "input_text", "text": "nested"}]}, "tail"]), - ), - ..Default::default() - }, - ) - .unwrap(); - cache - .get_cache( - "key1", - &SemanticCacheContext { - input: Some(json!({"output_text": " result text "})), - ..Default::default() - }, - ) - .unwrap(); - cache - .get_cache( - "key1", - &messages_context(vec![json!({ - "role": "user", - "content": "question", - "search_results": [{"source": "src", "title": "t", "content": [{"text": "found"}], "citations": {"a": 1}}], - })]), - ) - .unwrap(); - - assert_eq!( - *calls.lock().unwrap(), + let cache = cache( vec![ - "firstsecondreply", - "plain input", - "nested\ntail", - "result text", - "questionsrctfound{\"a\":1}", - ] + info(INDEX, base_info), + info_missing(&isolated), + create_index(&isolated, 3), + hset(&isolated, PROMPT, "key1", &VECTOR, &entry), + ], + FakeEmbedder::new(&[]), ); + + cache.set_cache("key1", entry, &context).unwrap(); } -#[test] -fn ttl_passes_through_context_only() { - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection( - MockRedisConnection::new(Vec::::new()), - embedder, - config(), +#[rstest] +fn create_index_race_rechecks_schema_and_stores(entry: Value, context: SemanticCacheContext) { + let cache = cache( + vec![ + info_missing(INDEX), + MockCmd::new( + create_index_command(INDEX, 3), + Err::<&str, _>(redis::RedisError::from(( + redis::ErrorKind::Extension, + "Index already exists", + ))), + ), + info(INDEX, compatible_info(3)), + hset(INDEX, PROMPT, "key1", &VECTOR, &entry), + ], + FakeEmbedder::new(&[]), ); - assert_eq!(cache.get_ttl(&SemanticCacheContext::default()), None); + + cache.set_cache("key1", entry, &context).unwrap(); +} + +#[rstest] +#[case::punctuation_and_spaces("a:b, c|d", "a\\:b\\,\\ c\\|d")] +#[case::braces_and_dots("{x}.y", "\\{x\\}\\.y")] +#[case::plain("key1", "key1")] +fn tag_special_characters_are_escaped_in_search_filter( + #[case] tag: &str, + #[case] escaped: &str, + context: SemanticCacheContext, +) { + let cache = cache( + vec![ + info(INDEX, compatible_info(3)), + search(INDEX, escaped, &VECTOR, Ok(empty_result())), + ], + FakeEmbedder::new(&[]), + ); + + assert_eq!(cache.get_cache(tag, &context).unwrap(), None); +} + +#[rstest] +#[case::content_parts( + SemanticCacheContext { + messages: Some(json!([ + {"role": "user", "content": [{"type": "text", "text": "first"}, {"type": "text", "text": "second"}]}, + {"role": "assistant", "content": "reply"}, + ])), + ..Default::default() + }, + "firstsecondreply" +)] +#[case::responses_string_input( + SemanticCacheContext { input: Some(json!(" plain input ")), ..Default::default() }, + "plain input" +)] +#[case::responses_nested_input( + SemanticCacheContext { + input: Some(json!([{"content": [{"type": "input_text", "text": "nested"}]}, "tail"])), + ..Default::default() + }, + "nested\ntail" +)] +#[case::responses_output_text( + SemanticCacheContext { input: Some(json!({"output_text": " result text "})), ..Default::default() }, + "result text" +)] +#[case::search_results( + messages_context(vec![json!({ + "role": "user", + "content": "question", + "search_results": [{"source": "src", "title": "t", "content": [{"text": "found"}], "citations": {"a": 1}}], + })]), + "questionsrctfound{\"a\":1}" +)] +#[case::empty_messages_fall_back_to_input( + SemanticCacheContext { messages: Some(json!([])), input: Some(json!("fallback")), ..Default::default() }, + "fallback" +)] +fn prompt_extraction_matches_python_message_and_input_shapes( + #[case] context: SemanticCacheContext, + #[case] prompt: &str, +) { + let embedder = FakeEmbedder::new(&[]); + let calls = embedder.calls.clone(); + let cache = cache( + vec![ + info(INDEX, compatible_info(3)), + search(INDEX, "key1", &VECTOR, Ok(empty_result())), + ], + embedder, + ); + + cache.get_cache("key1", &context).unwrap(); + + assert_eq!(*calls.lock().unwrap(), vec![(prompt.to_owned(), None)]); +} + +#[rstest] +#[case(None)] +#[case(Some(Duration::from_secs(9)))] +fn ttl_passes_through_context_only(#[case] ttl: Option) { + let cache = cache(Vec::new(), FakeEmbedder::new(&[])); + assert_eq!( cache.get_ttl(&SemanticCacheContext { - ttl: Some(Duration::from_secs(9)), + ttl, ..Default::default() }), - Some(Duration::from_secs(9)) + ttl ); } +#[rstest] #[tokio::test] -async fn async_paths_embed_then_run_blocking_redis_work() { - let vector = vec![0.1f32, 0.2, 0.3]; - let prompt = "hello prompt"; - let tag = "key1"; - let hash_key = format!("{INDEX}:{}", entry_id(prompt, tag)); - let value = entry(); - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(3))), - MockCmd::new( - redis::cmd("HSET") - .arg(&hash_key) - .arg("entry_id") - .arg(entry_id(prompt, tag)) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&value)) - .arg("prompt_vector") - .arg(vector_bytes(&vector)) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg(tag), - Ok(7), - ), - MockCmd::new( - search_command(INDEX, tag, &vector), - Ok(search_result(hit_fields(tag, "0.05", encoded(&value)))), - ), - ]) - .assert_all_commands_consumed(); - let (embedder, _) = FakeEmbedder::new(&[]); - let cache = RedisSemanticCache::with_connection(connection, embedder, config()) - .with_clock(|| 1700000000.5); - let context = messages_context(vec![json!({"role": "user", "content": prompt})]); +async fn async_paths_embed_with_metadata_then_run_blocking_redis_work( + entry: Value, + context: SemanticCacheContext, +) { + let embedder = FakeEmbedder::new(&[]); + let calls = embedder.calls.clone(); + let cache = cache( + vec![ + info(INDEX, compatible_info(3)), + hset(INDEX, PROMPT, "key1", &VECTOR, &entry), + search( + INDEX, + "key1", + &VECTOR, + Ok(hit("key1", "0.05", encoded(&entry))), + ), + ], + embedder, + ); + let context = SemanticCacheContext { + metadata: Some(json!({"tenant": "team"})), + ..context + }; cache - .async_set_cache(tag, value.clone(), context.clone()) + .async_set_cache("key1", entry.clone(), context.clone()) .await .unwrap(); assert_eq!( - cache.async_get_cache(tag, &context).await.unwrap(), - Some(value) + cache.async_get_cache("key1", &context).await.unwrap(), + Some(entry) + ); + assert_eq!( + *calls.lock().unwrap(), + vec![(PROMPT.to_owned(), context.metadata.clone()); 2] ); } -#[test] -fn shared_base_index_across_dimensions_replaces_the_isolated_index() { +#[rstest] +fn accessors_report_the_config(config: RedisSemanticConfig) { + let cache = cache(Vec::new(), FakeEmbedder::new(&[])); + + assert_eq!(cache.index_name(), config.index_name); + assert!((cache.similarity_threshold() - config.similarity_threshold).abs() < 1e-6); +} + +#[rstest] +fn shared_base_index_across_dimensions_replaces_the_isolated_index(entry: Value) { // Pins parity with Python's `_isolated` + overwrite=True flow. let prompt = "shared prompt"; - let tag = "key1"; let isolated = format!("{INDEX}_isolated"); - let value = entry(); let context = || messages_context(vec![json!({"role": "user", "content": prompt})]); - let store_hash = |index: &str, vector: &[f32]| { - MockCmd::new( - redis::cmd("HSET") - .arg(format!("{index}:{}", entry_id(prompt, tag))) - .arg("entry_id") - .arg(entry_id(prompt, tag)) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(encoded(&value)) - .arg("prompt_vector") - .arg(vector_bytes(vector)) - .arg("inserted_at") - .arg("1700000000.5") - .arg("updated_at") - .arg("1700000000.5") - .arg("litellm_cache_key") - .arg(tag), - Ok(7), - ) - }; let vector_a = vec![0.1f32; 8]; - let connection_a = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("FT.INFO").arg(INDEX), - Err::(unknown_index_error()), - ), - MockCmd::new(create_index_command(INDEX, 8), Ok("OK")), - store_hash(INDEX, &vector_a), - ]) - .assert_all_commands_consumed(); - let (embedder_a, _) = FakeEmbedder::new(&[(prompt, &vector_a)]); - let worker_a = RedisSemanticCache::with_connection(connection_a, embedder_a, config()) - .with_clock(|| 1700000000.5); - worker_a.set_cache(tag, value.clone(), &context()).unwrap(); + let worker_a = cache( + vec![ + info_missing(INDEX), + create_index(INDEX, 8), + hset(INDEX, prompt, "key1", &vector_a, &entry), + ], + FakeEmbedder::new(&[(prompt, &vector_a)]), + ); + worker_a + .set_cache("key1", entry.clone(), &context()) + .unwrap(); let vector_b = vec![0.2f32; 4]; - let connection_b = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(8))), - MockCmd::new( - redis::cmd("FT.INFO").arg(&isolated), - Err::(unknown_index_error()), - ), - MockCmd::new(create_index_command(&isolated, 4), Ok("OK")), - store_hash(&isolated, &vector_b), - MockCmd::new( - search_command(&isolated, tag, &vector_b), - Ok(search_result(hit_fields(tag, "0.0", encoded(&value)))), - ), - MockCmd::new( - search_command(&isolated, tag, &vector_b), - Err::(redis::RedisError::from(( - redis::ErrorKind::Extension, - "Vector dimension mismatch", - ))), - ), - ]) - .assert_all_commands_consumed(); - let (embedder_b, _) = FakeEmbedder::new(&[(prompt, &vector_b)]); - let worker_b = RedisSemanticCache::with_connection(connection_b, embedder_b, config()) - .with_clock(|| 1700000000.5); - worker_b.set_cache(tag, value.clone(), &context()).unwrap(); + let worker_b = cache( + vec![ + info(INDEX, compatible_info(8)), + info_missing(&isolated), + create_index(&isolated, 4), + hset(&isolated, prompt, "key1", &vector_b, &entry), + search( + &isolated, + "key1", + &vector_b, + Ok(hit("key1", "0.0", encoded(&entry))), + ), + search( + &isolated, + "key1", + &vector_b, + Err(redis::RedisError::from(( + redis::ErrorKind::Extension, + "Vector dimension mismatch", + ))), + ), + ], + FakeEmbedder::new(&[(prompt, &vector_b)]), + ); + worker_b + .set_cache("key1", entry.clone(), &context()) + .unwrap(); assert_eq!( - worker_b.get_cache(tag, &context()).unwrap(), - Some(value.clone()) + worker_b.get_cache("key1", &context()).unwrap(), + Some(entry.clone()) ); let vector_c = vec![0.3f32; 16]; - let connection_c = MockRedisConnection::new([ - MockCmd::new(redis::cmd("FT.INFO").arg(INDEX), Ok(compatible_info(8))), - MockCmd::new(redis::cmd("FT.INFO").arg(&isolated), Ok(compatible_info(4))), - MockCmd::new(redis::cmd("FT.DROPINDEX").arg(&isolated), Ok("OK")), - MockCmd::new(create_index_command(&isolated, 16), Ok("OK")), - store_hash(&isolated, &vector_c), - ]) - .assert_all_commands_consumed(); - let (embedder_c, _) = FakeEmbedder::new(&[(prompt, &vector_c)]); - let worker_c = RedisSemanticCache::with_connection(connection_c, embedder_c, config()) - .with_clock(|| 1700000000.5); - worker_c.set_cache(tag, value.clone(), &context()).unwrap(); + let worker_c = cache( + vec![ + info(INDEX, compatible_info(8)), + info(&isolated, compatible_info(4)), + MockCmd::new(redis::cmd("FT.DROPINDEX").arg(&isolated), Ok("OK")), + create_index(&isolated, 16), + hset(&isolated, prompt, "key1", &vector_c, &entry), + ], + FakeEmbedder::new(&[(prompt, &vector_c)]), + ); + worker_c + .set_cache("key1", entry.clone(), &context()) + .unwrap(); assert_eq!( - worker_b.get_cache(tag, &context()).unwrap_err(), + worker_b.get_cache("key1", &context()).unwrap_err(), Error::Unavailable ); } -#[test] -fn live_shared_index_is_replaced_across_dimensions() { - let Ok(url) = std::env::var("LITELLM_REDIS_STACK_URL") else { +#[fixture] +fn redis_stack_url() -> Option { + std::env::var("LITELLM_REDIS_STACK_URL").ok() +} + +fn live_cache( + url: &str, + index_name: &str, + prompt: &str, + vector: Vec, +) -> RedisSemanticCache> { + RedisSemanticCache::new( + url, + FakeEmbedder::new(&[(prompt, vector.as_slice())]), + JsonCodec::::new(), + RedisSemanticConfig { + index_name: index_name.to_owned(), + similarity_threshold: 0.9, + }, + ) + .unwrap() +} + +#[rstest] +fn live_shared_index_is_replaced_across_dimensions(redis_stack_url: Option, entry: Value) { + let Some(url) = redis_stack_url else { return; }; // Pins parity with Python's `_isolated` + overwrite=True flow. let base = format!("rust_semantic_shared_{}", std::process::id()); let isolated = format!("{base}_isolated"); let prompt = "shared live prompt"; - let tag = "key1"; let context = || messages_context(vec![json!({"role": "user", "content": prompt})]); - let value = entry(); - let worker = |vector: Vec| { - let (embedder, _) = FakeEmbedder::new(&[(prompt, vector.as_slice())]); - RedisSemanticCache::new( - &url, - embedder, - RedisSemanticConfig { - index_name: base.clone(), - similarity_threshold: 0.9, - }, - ) - .unwrap() - }; - let worker_a = worker(vec![0.1f32; 8]); - worker_a.set_cache(tag, value.clone(), &context()).unwrap(); + let worker_a = live_cache(&url, &base, prompt, vec![0.1f32; 8]); + worker_a + .set_cache("key1", entry.clone(), &context()) + .unwrap(); - let worker_b = worker(vec![0.2f32; 4]); - worker_b.set_cache(tag, value.clone(), &context()).unwrap(); + let worker_b = live_cache(&url, &base, prompt, vec![0.2f32; 4]); + worker_b + .set_cache("key1", entry.clone(), &context()) + .unwrap(); assert_eq!( - worker_b.get_cache(tag, &context()).unwrap(), - Some(value.clone()) + worker_b.get_cache("key1", &context()).unwrap(), + Some(entry.clone()) ); - let worker_c = worker(vec![0.3f32; 16]); - worker_c.set_cache(tag, value.clone(), &context()).unwrap(); + let worker_c = live_cache(&url, &base, prompt, vec![0.3f32; 16]); + worker_c + .set_cache("key1", entry.clone(), &context()) + .unwrap(); assert_eq!( - worker_b.get_cache(tag, &context()).unwrap_err(), + worker_b.get_cache("key1", &context()).unwrap_err(), Error::Unavailable ); @@ -961,39 +746,29 @@ fn live_shared_index_is_replaced_across_dimensions() { } } -#[test] -fn live_store_lookup_and_ttl_against_redis_stack() { - let Ok(url) = std::env::var("LITELLM_REDIS_STACK_URL") else { +#[rstest] +fn live_store_lookup_and_ttl_against_redis_stack(redis_stack_url: Option, entry: Value) { + let Some(url) = redis_stack_url else { return; }; - let vector = vec![0.1f32, 0.2, 0.3, 0.4]; let prompt = "rust semantic cache live prompt"; - let tag = "live-key"; let index_name = format!("rust_semantic_test_{}", std::process::id()); - let (embedder, _) = FakeEmbedder::new(&[(prompt, &vector)]); - let cache = RedisSemanticCache::new( - &url, - embedder, - RedisSemanticConfig { - index_name: index_name.clone(), - similarity_threshold: 0.9, - }, - ) - .unwrap(); + let cache = live_cache(&url, &index_name, prompt, vec![0.1, 0.2, 0.3, 0.4]); let context = SemanticCacheContext { ttl: Some(Duration::from_secs(120)), ..messages_context(vec![json!({"role": "user", "content": prompt})]) }; - let value = entry(); - cache.set_cache(tag, value.clone(), &context).unwrap(); - assert_eq!(cache.get_cache(tag, &context).unwrap(), Some(value)); + cache + .set_cache("live-key", entry.clone(), &context) + .unwrap(); + assert_eq!(cache.get_cache("live-key", &context).unwrap(), Some(entry)); assert_eq!(cache.get_cache("other-key", &context).unwrap(), None); let mut connection = redis::Client::open(url).unwrap().get_connection().unwrap(); let ttl: i64 = redis::Commands::ttl( &mut connection, - format!("{index_name}:{}", entry_id(prompt, tag)), + format!("{index_name}:{}", entry_id(prompt, "live-key")), ) .unwrap(); assert!( diff --git a/litellm-rust/crates/cache-redis-semantic/tests/contract.rs b/litellm-rust/crates/cache-redis-semantic/tests/contract.rs new file mode 100644 index 00000000000..fb3cda9616e --- /dev/null +++ b/litellm-rust/crates/cache-redis-semantic/tests/contract.rs @@ -0,0 +1,63 @@ +mod support; + +use litellm_cache::{JsonCodec, SemanticCacheContext, semantic::PreparedEmbedding}; +use litellm_cache_redis_semantic::{DEFAULT_INDEX_NAME, RedisSemanticCache, RedisSemanticConfig}; +use litellm_cache_testing as contract; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use support::FakeSearch; + +type Cache = RedisSemanticCache, FakeSearch>; + +const PREFIX: &str = "contract:"; + +#[fixture] +fn cache() -> Cache { + RedisSemanticCache::with_connection( + FakeSearch::default(), + PreparedEmbedding(vec![0.6, 0.8]), + JsonCodec::new(), + RedisSemanticConfig { + index_name: DEFAULT_INDEX_NAME.into(), + similarity_threshold: 0.9, + }, + ) +} + +#[fixture] +fn context() -> SemanticCacheContext { + SemanticCacheContext { + messages: Some(json!([{"role": "user", "content": "contract prompt"}])), + ..Default::default() + } +} + +#[rstest] +#[tokio::test] +async fn hit_and_miss(cache: Cache, context: SemanticCacheContext) { + contract::hit_and_miss(&cache, context, PREFIX, json!({"answer": 42})).await; +} + +#[rstest] +#[tokio::test] +async fn sync_async_equivalence(cache: Cache, context: SemanticCacheContext) { + contract::sync_async_equivalence(&cache, context, PREFIX, json!("first"), json!([2])).await; +} + +#[rstest] +#[tokio::test] +async fn overwrite_replaces(cache: Cache, context: SemanticCacheContext) { + contract::overwrite_replaces(&cache, context, PREFIX, json!(1), json!({"b": 2})).await; +} + +#[rstest] +#[tokio::test] +async fn pipeline_writes_every_entry(cache: Cache, context: SemanticCacheContext) { + contract::pipeline_writes_every_entry( + &cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; +} diff --git a/litellm-rust/crates/cache-redis-semantic/tests/support/mod.rs b/litellm-rust/crates/cache-redis-semantic/tests/support/mod.rs new file mode 100644 index 00000000000..0f5c2985c36 --- /dev/null +++ b/litellm-rust/crates/cache-redis-semantic/tests/support/mod.rs @@ -0,0 +1,299 @@ +#![allow(dead_code)] + +use std::{ + collections::{BTreeMap, HashMap}, + sync::{Arc, Mutex}, +}; + +use litellm_cache::{Error, semantic::Embedder}; +use serde_json::Value; + +pub type EmbedCalls = Arc)>>>; + +/// Embeds known prompts to fixed vectors, anything else to `[0.1, 0.2, 0.3]`, and records every +/// prompt with its metadata. +pub struct FakeEmbedder { + vectors: HashMap>, + pub calls: EmbedCalls, +} + +impl FakeEmbedder { + pub fn new(vectors: &[(&str, &[f32])]) -> Self { + Self { + vectors: vectors + .iter() + .map(|(prompt, vector)| ((*prompt).to_owned(), vector.to_vec())) + .collect(), + calls: EmbedCalls::default(), + } + } +} + +impl Embedder for FakeEmbedder { + fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { + self.calls + .lock() + .unwrap() + .push((prompt.to_owned(), metadata.cloned())); + Ok(self + .vectors + .get(prompt) + .cloned() + .unwrap_or_else(|| vec![0.1, 0.2, 0.3])) + } + + async fn async_embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { + self.embed(prompt, metadata) + } +} + +struct FakeIndex { + prefix: Vec, + dims: usize, + vector_field: String, +} + +#[derive(Default)] +struct SearchState { + indexes: HashMap, + hashes: BTreeMap, BTreeMap>>, +} + +/// An in-memory Redis Stack speaking the `FT.*`, `HSET` and `EXPIRE` subset the semantic cache +/// sends, with exact cosine KNN over the hashes under an index prefix. +#[derive(Clone, Default)] +pub struct FakeSearch { + state: Arc>, +} + +impl FakeSearch { + fn run(&self, args: Vec>) -> redis::RedisResult { + let mut state = self.state.lock().unwrap(); + let text = |index: usize| String::from_utf8_lossy(&args[index]).into_owned(); + match text(0).to_uppercase().as_str() { + "FT.CREATE" => { + let name = text(1); + if state.indexes.contains_key(&name) { + return Err(error("Index already exists")); + } + let position = |token: &str| args.iter().position(|arg| arg == token.as_bytes()); + let prefix = args[position("PREFIX").unwrap() + 2].clone(); + let dims = text(position("DIM").unwrap() + 1).parse().unwrap(); + let vector_field = text(position("VECTOR").unwrap() - 1); + state.indexes.insert( + name, + FakeIndex { + prefix, + dims, + vector_field, + }, + ); + Ok(redis::Value::Okay) + } + "FT.INFO" => { + let index = state + .indexes + .get(&text(1)) + .ok_or_else(|| error("Unknown index name"))?; + Ok(index_info(index)) + } + "FT.DROPINDEX" => { + state.indexes.remove(&text(1)); + Ok(redis::Value::Okay) + } + "HSET" => { + let hash = state.hashes.entry(args[1].clone()).or_default(); + for pair in args[2..].chunks(2) { + hash.insert( + String::from_utf8_lossy(&pair[0]).into_owned(), + pair[1].clone(), + ); + } + Ok(redis::Value::Int(((args.len() - 2) / 2) as i64)) + } + "EXPIRE" => Ok(redis::Value::Int(i64::from( + state.hashes.contains_key(&args[1]), + ))), + "FT.SEARCH" => { + let index = state + .indexes + .get(&text(1)) + .ok_or_else(|| error("no such index"))?; + let query = text(2); + let tag = query_tag(&query); + let params = args.iter().position(|arg| arg == b"PARAMS").unwrap(); + let vector = floats(&args[params + 3]); + let best = state + .hashes + .iter() + .filter(|(key, _)| key.starts_with(&index.prefix)) + .filter(|(_, fields)| { + fields.get("litellm_cache_key").map(Vec::as_slice) == Some(tag.as_bytes()) + }) + .filter_map(|(key, fields)| { + let stored = floats(fields.get(&index.vector_field)?); + (stored.len() == index.dims) + .then(|| (key, fields, 1.0 - cosine(&vector, &stored))) + }) + .min_by(|left, right| left.2.total_cmp(&right.2)); + let Some((key, fields, distance)) = best else { + return Ok(redis::Value::Array(vec![redis::Value::Int(0)])); + }; + let mut reply = fields + .iter() + .filter(|(name, _)| **name != index.vector_field) + .flat_map(|(name, value)| [bulk(name.as_bytes()), bulk(value)]) + .collect::>(); + reply.extend([ + bulk(b"vector_distance"), + bulk(distance.to_string().as_bytes()), + ]); + Ok(redis::Value::Array(vec![ + redis::Value::Int(1), + bulk(key), + redis::Value::Array(reply), + ])) + } + "PING" => Ok(redis::Value::SimpleString("PONG".into())), + _ => Err(error("unsupported command")), + } + } +} + +impl redis::ConnectionLike for FakeSearch { + fn req_packed_command(&mut self, command: &[u8]) -> redis::RedisResult { + let mut commands = parse_commands(command); + self.run(commands.remove(0)) + } + + fn req_packed_commands( + &mut self, + commands: &[u8], + offset: usize, + count: usize, + ) -> redis::RedisResult> { + let replies = parse_commands(commands) + .into_iter() + .map(|args| self.run(args)) + .collect::>>()?; + Ok(replies.into_iter().skip(offset).take(count).collect()) + } + + fn get_db(&self) -> i64 { + 0 + } + + fn check_connection(&mut self) -> bool { + true + } + + fn is_open(&self) -> bool { + true + } +} + +fn error(message: &'static str) -> redis::RedisError { + redis::RedisError::from((redis::ErrorKind::Extension, message)) +} + +fn bulk(bytes: &[u8]) -> redis::Value { + redis::Value::BulkString(bytes.to_vec()) +} + +fn index_info(index: &FakeIndex) -> redis::Value { + let attribute = |name: &str, field_type: &str| { + redis::Value::Array(vec![ + bulk(b"identifier"), + bulk(name.as_bytes()), + bulk(b"type"), + bulk(field_type.as_bytes()), + ]) + }; + redis::Value::Array(vec![ + bulk(b"attributes"), + redis::Value::Array(vec![ + attribute("prompt", "TEXT"), + attribute("response", "TEXT"), + attribute("inserted_at", "NUMERIC"), + attribute("updated_at", "NUMERIC"), + attribute("litellm_cache_key", "TAG"), + redis::Value::Array(vec![ + bulk(b"identifier"), + bulk(index.vector_field.as_bytes()), + bulk(b"type"), + bulk(b"VECTOR"), + bulk(b"dim"), + redis::Value::Int(index.dims as i64), + bulk(b"data_type"), + bulk(b"FLOAT32"), + bulk(b"distance_metric"), + bulk(b"COSINE"), + ]), + ]), + ]) +} + +/// The tag inside `@litellm_cache_key:{...}`, with query escapes removed. +fn query_tag(query: &str) -> String { + let start = query.find("@litellm_cache_key:{").unwrap() + "@litellm_cache_key:{".len(); + let mut tag = String::new(); + let mut characters = query[start..].chars(); + while let Some(character) = characters.next() { + match character { + '\\' => tag.extend(characters.next()), + '}' => break, + character => tag.push(character), + } + } + tag +} + +fn floats(bytes: &[u8]) -> Vec { + bytes + .as_chunks::<4>() + .0 + .iter() + .map(|chunk| f32::from_le_bytes(*chunk)) + .collect() +} + +fn cosine(left: &[f32], right: &[f32]) -> f64 { + let dot = left + .iter() + .zip(right) + .map(|(left, right)| f64::from(*left) * f64::from(*right)) + .sum::(); + let norm = |vector: &[f32]| { + vector + .iter() + .map(|value| f64::from(*value).powi(2)) + .sum::() + .sqrt() + }; + dot / (norm(left) * norm(right)) +} + +/// Splits a packed RESP request into each command's arguments. +fn parse_commands(mut bytes: &[u8]) -> Vec>> { + let line = |bytes: &mut &[u8]| { + let end = bytes + .windows(2) + .position(|window| window == b"\r\n") + .unwrap(); + let text = String::from_utf8(bytes[1..end].to_vec()).unwrap(); + *bytes = &bytes[end + 2..]; + text.parse::().unwrap() + }; + let mut commands = Vec::new(); + while !bytes.is_empty() { + let count = line(&mut bytes); + let mut args = Vec::with_capacity(count); + for _ in 0..count { + let length = line(&mut bytes); + args.push(bytes[..length].to_vec()); + bytes = &bytes[length + 2..]; + } + commands.push(args); + } + commands +} diff --git a/litellm-rust/crates/cache-redis/Cargo.toml b/litellm-rust/crates/cache-redis/Cargo.toml index ea937098698..a234286a338 100644 --- a/litellm-rust/crates/cache-redis/Cargo.toml +++ b/litellm-rust/crates/cache-redis/Cargo.toml @@ -12,5 +12,7 @@ r2d2 = "0.8.10" tokio.workspace = true [dev-dependencies] +litellm-cache-testing.workspace = true redis-test = "1.0.4" +rstest.workspace = true serde_json.workspace = true diff --git a/litellm-rust/crates/cache-redis/src/cache.rs b/litellm-rust/crates/cache-redis/src/cache.rs index 24399c9b2f9..ebcf0b6916b 100644 --- a/litellm-rust/crates/cache-redis/src/cache.rs +++ b/litellm-rust/crates/cache-redis/src/cache.rs @@ -1,110 +1,25 @@ use std::{ - sync::{Arc, Mutex}, + sync::{Arc, OnceLock}, time::Duration, }; -use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheCodec, CacheConnectionResult, CacheConnectionStatus, - ClaimCache, CounterCache, DeleteCache, Error, ExactCacheContext, FlushCache, -}; -use redis::Commands; +use litellm_cache::{BatchEntry, CacheCodec, Error}; -use crate::topology::RedisTopology; - -mod connection; -mod operations; - -pub use connection::ConnectionRef; -use connection::{ClusterConnectionManager, ConnectionManager}; - -pub use operations::{ - RedisArg, RedisLpopOperation, RedisLpopResult, RedisRpushOperation, RedisScript, +use crate::{ + connection::{ConnectionRef, Connections}, + topology::RedisTopology, }; const DEFAULT_TTL: Duration = Duration::from_secs(600); -const REDIS_TIMEOUT: Duration = Duration::from_secs(5); -const REDIS_POOL_SIZE: u32 = 16; - -const INCREMENT_SCRIPT: &str = concat!( - "local value = redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]); ", - "if redis.call('TTL', KEYS[1]) == -1 then ", - "redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return value" -); - -const CLAIM_SCRIPT: &str = concat!( - "local current = redis.call('GET', KEYS[1]); ", - "if ARGV[1] == '' then if current ~= false and current ~= '' then return 0; end; ", - "elseif current ~= ARGV[1] then return 0; end; ", - "if ARGV[3] ~= '' then redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[2]); ", - "elseif ARGV[4] == '1' then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return 1" -); -const CLAIM_ATTEMPTS: usize = 8; - -#[allow(private_interfaces)] -pub enum Connections { - Pool(r2d2::Pool), - Cluster(r2d2::Pool), - Fixed(Mutex), -} - -impl Connections -where - C: redis::ConnectionLike + Send + 'static, -{ - pub fn execute( - &self, - operation: impl FnOnce(&mut ConnectionRef<'_>) -> Result, - ) -> Result { - match self { - Self::Pool(pool) => { - let mut pooled = pool.get().map_err(|_| Error::Unavailable)?; - let result = operation(&mut ConnectionRef::Node(&mut pooled.connection)); - pooled.failed = matches!(result, Err(Error::Unavailable)); - result - } - Self::Cluster(pool) => { - let mut pooled = pool.get().map_err(|_| Error::Unavailable)?; - let result = operation(&mut ConnectionRef::Cluster(&mut pooled.connection)); - pooled.failed = matches!(result, Err(Error::Unavailable)); - result - } - Self::Fixed(connection) => { - let mut connection = connection.lock().map_err(|_| Error::Unavailable)?; - operation(&mut ConnectionRef::Node(&mut *connection)) - } - } - } - - pub async fn run_blocking(connections: Arc, operation: F) -> Result - where - T: Send + 'static, - F: FnOnce(&mut ConnectionRef<'_>) -> Result + Send + 'static, - { - tokio::task::spawn_blocking(move || connections.execute(operation)) - .await - .map_err(|_| Error::Unavailable)? - } - - pub fn fixed(connection: C) -> Self { - Self::Fixed(Mutex::new(connection)) - } - - pub fn open(url: &str, topology: &RedisTopology) -> Result { - match topology { - RedisTopology::Standalone => Ok(Self::Pool(pool(ConnectionManager::open(url)?)?)), - RedisTopology::Cluster { startup_nodes } => Ok(Self::Cluster(pool( - ClusterConnectionManager::open(url, startup_nodes)?, - )?)), - } - } -} pub struct RedisCache { - connections: Arc>, - default_ttl: Duration, - codec: S, - namespace: Option, - topology: RedisTopology, + pub(crate) connections: Arc>, + pub(crate) default_ttl: Duration, + pub(crate) codec: S, + pub(crate) namespace: Option, + pub(crate) topology: RedisTopology, + /// The server's major version, read from `INFO` once, like Python's `redis_version`. + pub(crate) major_version: Arc>, } impl RedisCache { @@ -125,20 +40,11 @@ impl RedisCache { codec, namespace: None, topology: topology.clone(), + major_version: Arc::default(), }) } } -fn pool(manager: M) -> Result, Error> { - r2d2::Pool::builder() - .max_size(REDIS_POOL_SIZE) - .min_idle(Some(0)) - .connection_timeout(REDIS_TIMEOUT) - .test_on_check_out(false) - .build(manager) - .map_err(|_| Error::Unavailable) -} - impl RedisCache where S: CacheCodec, @@ -151,6 +57,7 @@ where codec, namespace: None, topology: RedisTopology::Standalone, + major_version: Arc::default(), } } @@ -169,11 +76,52 @@ where &self.topology } - fn namespaced_key(&self, key: &str) -> String { + pub(crate) fn namespaced_key(&self, key: &str) -> String { namespaced_key(self.namespace.as_deref(), key) } - fn namespaced_pattern(&self) -> Result { + pub(crate) fn namespaced_keys(&self, keys: &[String]) -> Vec { + keys.iter().map(|key| self.namespaced_key(key)).collect() + } + + /// Whole seconds for `ttl`, falling back to the default TTL like Python's `get_ttl`. + pub(crate) fn ttl_or_default(&self, ttl: Option) -> u64 { + ttl_seconds(ttl.unwrap_or(self.default_ttl)) + } + + pub(crate) fn execute( + &self, + operation: impl FnOnce(&mut ConnectionRef<'_>) -> Result, + ) -> Result { + self.connections.execute(operation) + } + + /// `_parse_redis_major_version`: the major version from `INFO`, or + /// `DEFAULT_REDIS_MAJOR_VERSION` when `INFO` fails or its version does not parse. The first + /// answer is kept, as Python reads `redis_version` once at construction. + pub(crate) async fn major_version(&self) -> u32 { + if let Some(version) = self.major_version.get() { + return *version; + } + let info = self + .run(|connection| connection.node_text(&redis::cmd("INFO"))) + .await; + let version = info + .ok() + .and_then(|info| parse_major_version(&info)) + .unwrap_or_else(default_major_version); + *self.major_version.get_or_init(|| version) + } + + pub(crate) async fn run(&self, operation: F) -> Result + where + T: Send + 'static, + F: FnOnce(&mut ConnectionRef<'_>) -> Result + Send + 'static, + { + Connections::run_blocking(Arc::clone(&self.connections), operation).await + } + + pub(crate) fn namespaced_pattern(&self) -> Result { let namespace = self.namespace.as_ref().ok_or(Error::UnscopedFlush)?; let escaped: String = namespace .chars() @@ -188,18 +136,7 @@ where Ok(format!("{escaped}:*")) } - fn flush_matching(connection: &mut ConnectionRef<'_>, pattern: &str) -> Result<(), Error> { - connection.scan(pattern, 1000, |connection, keys| { - if !keys.is_empty() { - connection - .del::<_, usize>(keys) - .map_err(|_| Error::Unavailable)?; - } - Ok(true) - }) - } - - fn decode_response(&self, value: redis::Value) -> Result, Error> { + pub(crate) fn decode_response(&self, value: redis::Value) -> Result, Error> { match value { redis::Value::Nil => Ok(None), redis::Value::BulkString(bytes) => self.codec.decode(&bytes).map(Some), @@ -208,7 +145,10 @@ where } } - fn decode_batch_response(&self, value: redis::Value) -> Result, Error> { + pub(crate) fn decode_batch_response( + &self, + value: redis::Value, + ) -> Result, Error> { match self.decode_response(value) { Ok(Some(value)) => Ok(BatchEntry::Hit(value)), Ok(None) => Ok(BatchEntry::Miss), @@ -216,15 +156,9 @@ where Err(error) => Err(error), } } - - fn ttl_seconds(ttl: Duration) -> u64 { - ttl.as_secs() - .saturating_add(u64::from(ttl.subsec_nanos() > 0)) - .max(1) - } } -fn namespaced_key(namespace: Option<&str>, key: &str) -> String { +pub(crate) fn namespaced_key(namespace: Option<&str>, key: &str) -> String { match namespace { Some(namespace) if !key.starts_with(&format!("{namespace}:")) => { format!("{namespace}:{key}") @@ -233,469 +167,26 @@ fn namespaced_key(namespace: Option<&str>, key: &str) -> String { } } -impl BaseCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - type Value = S::Value; - type Context = ExactCacheContext; +pub(crate) fn ttl_seconds(ttl: Duration) -> u64 { + ttl.as_secs() + .saturating_add(u64::from(ttl.subsec_nanos() > 0)) + .max(1) +} - fn get_ttl(&self, context: &Self::Context) -> Option { - context.ttl.or(Some(self.default_ttl)) - } - - fn set_cache( - &self, - key: &str, - value: Self::Value, - context: &ExactCacheContext, - ) -> Result<(), Error> { - let payload = self.codec.encode(&value)?; - let ttl = Self::ttl_seconds(self.get_ttl(context).unwrap_or(self.default_ttl)); - let key = self.namespaced_key(key); - self.connections.execute(|connection| { - connection - .set_ex::<_, _, ()>(key, payload, ttl) - .map_err(|_| Error::Unavailable) - }) - } - - fn get_cache(&self, key: &str, _: &ExactCacheContext) -> Result, Error> { - let key = self.namespaced_key(key); - let value = self.connections.execute(|connection| { - connection - .get::<_, redis::Value>(key) - .map_err(|_| Error::Unavailable) - })?; - self.decode_response(value) - } - - async fn async_set_cache( - &self, - key: &str, - value: Self::Value, - context: ExactCacheContext, - ) -> Result<(), Error> { - let payload = self.codec.encode(&value)?; - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(self.get_ttl(&context).unwrap_or(self.default_ttl)); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - connection - .set_ex::<_, _, ()>(key, payload, ttl) - .map_err(|_| Error::Unavailable) - }) - .await - } - - async fn async_get_cache( - &self, - key: &str, - _: &ExactCacheContext, - ) -> Result, Error> { - let key = self.namespaced_key(key); - let value = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - connection - .get::<_, redis::Value>(key) - .map_err(|_| Error::Unavailable) - }) - .await?; - self.decode_response(value) - } - - async fn async_set_cache_pipeline( - &self, - cache_list: Vec<(String, Self::Value)>, - context: ExactCacheContext, - ) -> Result<(), Error> { - let entries = cache_list - .into_iter() - .map(|(key, value)| { - self.codec - .encode(&value) - .map(|payload| (self.namespaced_key(&key), payload)) - }) - .collect::, _>>()?; - let ttl = Self::ttl_seconds(self.get_ttl(&context).unwrap_or(self.default_ttl)); - if entries.is_empty() { - return Ok(()); - } - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let commands = entries - .into_iter() - .map(|(key, payload)| { - let mut command = redis::cmd("SETEX"); - command.arg(key).arg(ttl).arg(payload); - command - }) - .collect(); - connection.pipeline(commands).map(drop) - }) - .await - } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - match Connections::run_blocking(Arc::clone(&self.connections), |connection| { - Ok(match connection.ping() { - Ok(_) => CacheConnectionResult { - status: CacheConnectionStatus::Success, - message: "Redis cache connection test successful".into(), - error: None, - }, - Err(error) => CacheConnectionResult { - status: CacheConnectionStatus::Failed, - message: format!("Redis connection failed: {error}"), - error: Some(error.to_string()), - }, - }) - }) - .await - { - Ok(result) => Ok(result), - Err(error) => Ok(CacheConnectionResult { - status: CacheConnectionStatus::Failed, - message: format!("Redis connection failed: {error}"), - error: Some(error.to_string()), - }), - } +fn parse_major_version(info: &str) -> Option { + let version = info + .lines() + .find_map(|line| line.trim().strip_prefix("redis_version:"))? + .trim(); + match version.split_once('.') { + Some((major, _)) => major.parse().ok(), + None => version.parse::().ok().map(|major| major as u32), } } -impl BatchCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - fn batch_get_cache( - &self, - keys: &[String], - _: &ExactCacheContext, - ) -> Result>, Error> { - let keys = keys - .iter() - .map(|key| self.namespaced_key(key)) - .collect::>(); - let values = self.connections.execute(|connection| { - redis::cmd("MGET") - .arg(keys) - .query::>(connection) - .map_err(|_| Error::Unavailable) - })?; - values - .into_iter() - .map(|value| self.decode_batch_response(value)) - .collect() - } - - async fn async_batch_get_cache( - &self, - keys: Vec, - _: ExactCacheContext, - ) -> Result>, Error> { - let keys = keys - .iter() - .map(|key| self.namespaced_key(key)) - .collect::>(); - let values = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - redis::cmd("MGET") - .arg(keys) - .query::>(connection) - .map_err(|_| Error::Unavailable) - }) - .await?; - values - .into_iter() - .map(|value| self.decode_batch_response(value)) - .collect() - } -} - -impl DeleteCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - fn delete_cache(&self, key: &str) -> Result<(), Error> { - let key = self.namespaced_key(key); - self.connections - .execute(|connection| connection.del::<_, ()>(key).map_err(|_| Error::Unavailable)) - } - - async fn async_delete_cache(&self, key: &str) -> Result<(), Error> { - let key = self.namespaced_key(key); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - connection.del::<_, ()>(key).map_err(|_| Error::Unavailable) - }) - .await - } -} - -impl FlushCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - fn flush_cache(&self) -> Result<(), Error> { - let pattern = self.namespaced_pattern()?; - self.connections - .execute(|connection| Self::flush_matching(connection, &pattern)) - } - - async fn async_flush_cache(&self) -> Result<(), Error> { - let pattern = self.namespaced_pattern()?; - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - Self::flush_matching(connection, &pattern) - }) - .await - } -} - -impl CounterCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - fn increment_cache( - &self, - key: &str, - amount: f64, - context: ExactCacheContext, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(self.get_ttl(&context).unwrap_or(self.default_ttl)); - self.connections - .execute(|connection| increment(connection, key, amount, ttl)) - } - - async fn async_increment( - &self, - key: &str, - amount: f64, - context: ExactCacheContext, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(self.get_ttl(&context).unwrap_or(self.default_ttl)); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - increment(connection, key, amount, ttl) - }) - .await - } -} - -fn increment( - connection: &mut ConnectionRef<'_>, - key: String, - amount: f64, - ttl: u64, -) -> Result { - redis::cmd("EVAL") - .arg(INCREMENT_SCRIPT) - .arg(1) - .arg(key) - .arg(amount) - .arg(ttl) - .query(connection) - .map_err(|_| Error::Unavailable) -} - -fn stored_bytes(value: redis::Value) -> Result>, Error> { - match value { - redis::Value::Nil => Ok(None), - redis::Value::BulkString(bytes) => Ok(Some(bytes)), - redis::Value::SimpleString(text) => Ok(Some(text.into_bytes())), - _ => Err(Error::InvalidEntry), - } -} - -/// Eligibility is decided on decoded values, so a pin written by another encoder (Python's -/// `json.dumps` spacing or key order) still matches. The write is a compare-and-set on the -/// bytes that decision was made on, retried when another claimant wins the race. -fn claim( - connection: &mut ConnectionRef<'_>, - codec: &S, - key: &str, - candidate: S::Value, - eligible: &[S::Value], - ttl: u64, -) -> Result -where - S::Value: PartialEq, -{ - let payload = codec.encode(&candidate)?; - if payload.is_empty() { - return Err(Error::InvalidEntry); - } - for _ in 0..CLAIM_ATTEMPTS { - let current = stored_bytes( - connection - .get::<_, redis::Value>(key) - .map_err(|_| Error::Unavailable)?, - )? - .filter(|bytes| !bytes.is_empty()); - let existing = current - .as_deref() - .and_then(|bytes| codec.decode(bytes).ok()) - .filter(|existing| eligible.is_empty() || eligible.contains(existing)); - let refresh = existing - .as_ref() - .is_some_and(|existing| !eligible.is_empty() || *existing == candidate); - let write: &[u8] = if existing.is_some() { b"" } else { &payload }; - let applied = redis::cmd("EVAL") - .arg(CLAIM_SCRIPT) - .arg(1) - .arg(key) - .arg(current.as_deref().unwrap_or_default()) - .arg(ttl) - .arg(write) - .arg(u8::from(refresh)) - .query::(connection) - .map_err(|_| Error::Unavailable)?; - if applied { - return Ok(existing.unwrap_or(candidate)); - } - } - Err(Error::Unavailable) -} - -impl ClaimCache for RedisCache -where - S: CacheCodec + Clone + 'static, - S::Value: PartialEq, - C: redis::ConnectionLike + Send + 'static, -{ - fn claim_cache( - &self, - key: &str, - candidate: S::Value, - eligible: &[S::Value], - context: ExactCacheContext, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(self.get_ttl(&context).unwrap_or(self.default_ttl)); - self.connections - .execute(|connection| claim(connection, &self.codec, &key, candidate, eligible, ttl)) - } - - async fn async_claim_cache( - &self, - key: &str, - candidate: S::Value, - eligible: Vec, - context: ExactCacheContext, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(self.get_ttl(&context).unwrap_or(self.default_ttl)); - let codec = self.codec.clone(); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - claim(connection, &codec, &key, candidate, &eligible, ttl) - }) - .await - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use litellm_cache::{ - BaseCache, CacheCodec, DeleteCache, ExactCacheContext, FlushCache, JsonCodec, - }; - use redis_test::{MockCmd, MockRedisConnection}; - use serde_json::json; - - use super::RedisCache; - - fn entry() -> serde_json::Value { - json!({"deployment": "model-a", "cooldown_seconds": 30}) - } - - #[test] - fn ttl_seconds_rounds_up_and_keeps_expiration_positive() { - assert_eq!( - RedisCache::>::ttl_seconds(Duration::ZERO), - 1 - ); - assert_eq!( - RedisCache::>::ttl_seconds(Duration::from_millis(1500)), - 2 - ); - assert_eq!( - RedisCache::>::ttl_seconds(Duration::from_secs(15)), - 15 - ); - } - - #[test] - fn redis_commands_round_trip_entries_and_delete_only_namespaced_keys() { - let value = entry(); - let payload = JsonCodec::::new() - .encode(&value) - .unwrap(); - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("SETEX") - .arg("litellm-cache:key") - .arg(600) - .arg(payload.clone()), - Ok("OK"), - ), - MockCmd::new(redis::cmd("GET").arg("litellm-cache:key"), Ok(payload)), - MockCmd::new(redis::cmd("DEL").arg("litellm-cache:key"), Ok(1u32)), - ]) - .assert_all_commands_consumed(); - let cache = - RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("litellm-cache".into())); - - cache - .set_cache("key", value.clone(), &ExactCacheContext::default()) - .unwrap(); - assert_eq!( - cache - .get_cache("key", &ExactCacheContext::default()) - .unwrap(), - Some(value) - ); - cache.delete_cache("key").unwrap(); - } - - #[test] - fn flush_scans_and_deletes_only_cache_keys() { - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("SCAN") - .cursor_arg(0) - .arg("MATCH") - .arg("litellm-cache:*") - .arg("COUNT") - .arg(1000), - Ok(redis_test::redis_value!(["0", ["litellm-cache:key"]])), - ), - MockCmd::new(redis::cmd("DEL").arg("litellm-cache:key"), Ok(1u32)), - ]) - .assert_all_commands_consumed(); - let cache = - RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("litellm-cache".into())); - - cache.flush_cache().unwrap(); - } - - #[tokio::test] - async fn test_connection_runs_ping_off_executor() { - let connection = MockRedisConnection::new([MockCmd::new(redis::cmd("PING"), Ok("PONG"))]) - .assert_all_commands_consumed(); - let cache = - RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("litellm-cache".into())); - - assert_eq!( - cache.test_connection().await.unwrap().status, - litellm_cache::CacheConnectionStatus::Success - ); - } +fn default_major_version() -> u32 { + std::env::var("DEFAULT_REDIS_MAJOR_VERSION") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(7) } diff --git a/litellm-rust/crates/cache-redis/src/cache/operations.rs b/litellm-rust/crates/cache-redis/src/cache/operations.rs deleted file mode 100644 index 4345ee879b3..00000000000 --- a/litellm-rust/crates/cache-redis/src/cache/operations.rs +++ /dev/null @@ -1,632 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use litellm_cache::{ - CacheCodec, CacheScript, ClientInfoCache, Error, IncrementOperation, QueueCache, ScanCache, - ScriptCache, SetCache, TtlCache, -}; -use redis::Commands; - -use super::{ConnectionRef, Connections, RedisCache, namespaced_key}; - -const INCREMENT_WITH_FLOOR_SCRIPT: &str = concat!( - "local count = redis.call('INCRBY', KEYS[1], ARGV[1]); ", - "if count < 0 then count = redis.call('INCRBY', KEYS[1], -count); end; ", - "if redis.call('TTL', KEYS[1]) < 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; ", - "return count" -); -const SET_MAX_SCRIPT: &str = concat!( - "local current = redis.call('GET', KEYS[1]); ", - "if current == false or tonumber(current) < tonumber(ARGV[1]) then ", - "redis.call('SET', KEYS[1], ARGV[1]); ", - "if tonumber(ARGV[2]) > 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; ", - "return ARGV[1]; end; return current" -); - -#[derive(Clone, Debug, PartialEq)] -pub enum RedisArg { - Bytes(Vec), - Integer(i64), - Float(f64), -} - -impl From<&str> for RedisArg { - fn from(value: &str) -> Self { - Self::Bytes(value.as_bytes().to_vec()) - } -} - -impl From for RedisArg { - fn from(value: String) -> Self { - Self::Bytes(value.into_bytes()) - } -} - -impl From> for RedisArg { - fn from(value: Vec) -> Self { - Self::Bytes(value) - } -} - -impl From for RedisArg { - fn from(value: i64) -> Self { - Self::Integer(value) - } -} - -impl From for RedisArg { - fn from(value: f64) -> Self { - Self::Float(value) - } -} - -impl redis::ToRedisArgs for RedisArg { - fn write_redis_args(&self, out: &mut W) - where - W: ?Sized + redis::RedisWrite, - { - match self { - Self::Bytes(value) => value.write_redis_args(out), - Self::Integer(value) => value.write_redis_args(out), - Self::Float(value) => value.write_redis_args(out), - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct RedisRpushOperation { - pub key: String, - pub values: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RedisLpopOperation { - pub key: String, - pub count: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RedisLpopResult { - Missing, - Value(Vec), - Values(Vec>), -} - -pub struct RedisScript { - connections: Arc>, - namespace: Option, - source: String, -} - -impl CacheScript for RedisScript -where - C: redis::ConnectionLike + Send + 'static, -{ - type Argument = RedisArg; - type Output = redis::Value; - - async fn invoke( - &self, - keys: Vec, - arguments: Vec, - ) -> Result { - let keys = keys - .into_iter() - .map(|key| namespaced_key(self.namespace.as_deref(), &key)) - .collect::>(); - let connections = Arc::clone(&self.connections); - let source = self.source.clone(); - tokio::task::spawn_blocking(move || { - connections.execute(|connection| { - redis::cmd("EVAL") - .arg(source) - .arg(keys.len()) - .arg(keys) - .arg(arguments) - .query(connection) - .map_err(|_| Error::Unavailable) - }) - }) - .await - .map_err(|_| Error::Unavailable)? - } -} - -impl RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - pub async fn delete_cache_keys(&self, keys: Vec) -> Result { - if keys.is_empty() { - return Ok(0); - } - let keys = keys - .into_iter() - .map(|key| self.namespaced_key(&key)) - .collect::>(); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - connection.del(keys).map_err(|_| Error::Unavailable) - }) - .await - } - - pub fn batch_get_counts(&self, keys: &[String]) -> Result>, Error> { - let keys = keys - .iter() - .map(|key| self.namespaced_key(key)) - .collect::>(); - let values = self.connections.execute(|connection| { - redis::cmd("MGET") - .arg(keys) - .query::>(connection) - .map_err(|_| Error::Unavailable) - })?; - values.into_iter().map(count).collect() - } - - pub async fn async_batch_get_counts( - &self, - keys: Vec, - ) -> Result>, Error> { - let keys = keys - .iter() - .map(|key| self.namespaced_key(key)) - .collect::>(); - let values = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - redis::cmd("MGET") - .arg(keys) - .query::>(connection) - .map_err(|_| Error::Unavailable) - }) - .await?; - values.into_iter().map(count).collect() - } - - pub fn sync_ping(&self) -> Result { - self.connections - .execute(|connection| connection.ping().map_err(|_| Error::Unavailable)) - } - - pub async fn ping(&self) -> Result { - Connections::run_blocking(Arc::clone(&self.connections), |connection| { - connection.ping().map_err(|_| Error::Unavailable) - }) - .await - } - - pub async fn async_get_ttl(&self, key: &str) -> Result, Error> { - let key = self.namespaced_key(key); - let ttl = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - redis::cmd("TTL") - .arg(key) - .query::(connection) - .map_err(|_| Error::Unavailable) - }) - .await?; - Ok((ttl >= 0).then_some(ttl)) - } - - pub async fn async_scan_iter(&self, pattern: &str, count: usize) -> Result, Error> { - let pattern = format!("{}*", self.namespaced_key(pattern)); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let mut matches = Vec::new(); - connection.scan(&pattern, count, |_, keys| { - matches.extend(keys); - Ok(matches.len() < count) - })?; - matches.truncate(count); - Ok(matches) - }) - .await - } - - pub async fn async_set_cache_sadd( - &self, - key: &str, - values: Vec, - ttl: Option, - ) -> Result { - if values.is_empty() { - return Err(Error::InvalidEntry); - } - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(ttl.unwrap_or(self.default_ttl)); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let mut sadd = redis::cmd("SADD"); - sadd.arg(&key).arg(values); - let mut expire = redis::cmd("EXPIRE"); - expire.arg(&key).arg(ttl); - let replies = connection.pipeline(vec![sadd, expire])?; - replies - .into_iter() - .next() - .map(redis::from_redis_value::) - .transpose() - .map_err(|_| Error::Unavailable)? - .ok_or(Error::Unavailable) - }) - .await - } - - pub async fn async_rpush(&self, key: &str, values: Vec) -> Result { - if values.is_empty() { - return Err(Error::InvalidEntry); - } - let key = self.namespaced_key(key); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - redis::cmd("RPUSH") - .arg(key) - .arg(values) - .query(connection) - .map_err(|_| Error::Unavailable) - }) - .await - } - - pub async fn async_rpush_pipeline( - &self, - operations: Vec, - ) -> Result, Error> { - let operations = operations - .into_iter() - .map(|operation| { - if operation.values.is_empty() { - return Err(Error::InvalidEntry); - } - Ok((self.namespaced_key(&operation.key), operation.values)) - }) - .collect::, _>>()?; - if operations.is_empty() { - return Ok(Vec::new()); - } - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let commands = operations - .into_iter() - .map(|(key, values)| { - let mut command = redis::cmd("RPUSH"); - command.arg(key).arg(values); - command - }) - .collect(); - connection - .pipeline(commands)? - .into_iter() - .map(|value| redis::from_redis_value(value).map_err(|_| Error::Unavailable)) - .collect() - }) - .await - } - - pub async fn async_lpop( - &self, - key: &str, - count: Option, - ) -> Result { - let key = self.namespaced_key(key); - let multiple = count.is_some(); - let value = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let mut command = redis::cmd("LPOP"); - command.arg(key); - if let Some(count) = count { - command.arg(count); - } - command - .query::(connection) - .map_err(|_| Error::Unavailable) - }) - .await?; - lpop_result(value, multiple) - } - - pub async fn async_lpop_pipeline( - &self, - operations: Vec, - ) -> Result, Error> { - let operations = operations - .into_iter() - .map(|operation| (self.namespaced_key(&operation.key), operation.count)) - .collect::>(); - if operations.is_empty() { - return Ok(Vec::new()); - } - let multiple = operations - .iter() - .map(|(_, count)| count.is_some()) - .collect::>(); - let values = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let commands = operations - .into_iter() - .map(|(key, count)| { - let mut command = redis::cmd("LPOP"); - command.arg(key); - if let Some(count) = count { - command.arg(count); - } - command - }) - .collect(); - connection.pipeline(commands) - }) - .await?; - values - .into_iter() - .zip(multiple) - .map(|(value, multiple)| lpop_result(value, multiple)) - .collect() - } - - pub async fn async_eval( - &self, - script: String, - keys: Vec, - arguments: Vec, - ) -> Result { - let keys = keys - .into_iter() - .map(|key| self.namespaced_key(&key)) - .collect::>(); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - redis::cmd("EVAL") - .arg(script) - .arg(keys.len()) - .arg(keys) - .arg(arguments) - .query(connection) - .map_err(|_| Error::Unavailable) - }) - .await - } - - pub fn client_list(&self) -> Result { - self.connections - .execute(|connection| connection.node_text(redis::cmd("CLIENT").arg("LIST"))) - } - - pub fn info(&self) -> Result { - self.connections - .execute(|connection| connection.node_text(&redis::cmd("INFO"))) - } - - pub fn flushall(&self) -> Result<(), Error> { - self.connections.execute(|connection| connection.flushall()) - } -} - -impl RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - pub fn increment_with_floor( - &self, - key: &str, - amount: i64, - ttl: Duration, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(ttl); - self.connections - .execute(|connection| increment_with_floor(connection, key, amount, ttl)) - } - - pub async fn async_increment_pipeline( - &self, - operations: Vec, - ) -> Result, Error> { - let operations = operations - .into_iter() - .map(|operation| { - ( - self.namespaced_key(&operation.key), - operation.amount, - operation.ttl.map(Self::ttl_seconds), - ) - }) - .collect::>(); - if operations.is_empty() { - return Ok(Vec::new()); - } - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - let mut commands = Vec::with_capacity(operations.len() * 2); - let mut increments = Vec::with_capacity(operations.len()); - for (key, amount, ttl) in operations { - let mut increment = redis::cmd("INCRBYFLOAT"); - increment.arg(&key).arg(amount); - increments.push(commands.len()); - commands.push(increment); - if let Some(ttl) = ttl { - let mut expire = redis::cmd("EXPIRE"); - expire.arg(key).arg(ttl); - commands.push(expire); - } - } - let mut replies = connection.pipeline(commands)?; - increments - .into_iter() - .map(|index| { - redis::from_redis_value(std::mem::take(&mut replies[index])) - .map_err(|_| Error::Unavailable) - }) - .collect() - }) - .await - } - - pub async fn async_increment_with_floor( - &self, - key: &str, - amount: i64, - ttl: Duration, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(ttl); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - increment_with_floor(connection, key, amount, ttl) - }) - .await - } - - pub async fn async_set_max( - &self, - key: &str, - value: f64, - ttl: Option, - ) -> Result { - let key = self.namespaced_key(key); - let ttl = Self::ttl_seconds(ttl.unwrap_or(self.default_ttl)); - Connections::run_blocking(Arc::clone(&self.connections), move |connection| { - redis::cmd("EVAL") - .arg(SET_MAX_SCRIPT) - .arg(1) - .arg(key) - .arg(value) - .arg(ttl) - .query(connection) - .map_err(|_| Error::Unavailable) - }) - .await - } -} - -fn redis_bytes(value: redis::Value) -> Result, Error> { - match value { - redis::Value::BulkString(bytes) => Ok(bytes), - redis::Value::SimpleString(text) => Ok(text.into_bytes()), - _ => Err(Error::InvalidEntry), - } -} - -fn lpop_result(value: redis::Value, multiple: bool) -> Result { - match value { - redis::Value::Nil => Ok(RedisLpopResult::Missing), - redis::Value::Array(values) if multiple => values - .into_iter() - .map(redis_bytes) - .collect::, _>>() - .map(RedisLpopResult::Values), - value if !multiple => redis_bytes(value).map(RedisLpopResult::Value), - _ => Err(Error::InvalidEntry), - } -} - -fn count(value: redis::Value) -> Result, Error> { - match value { - redis::Value::Nil => Ok(None), - redis::Value::Int(value) => Ok(Some(value)), - redis::Value::BulkString(value) => std::str::from_utf8(&value) - .ok() - .and_then(|value| value.parse().ok()) - .map(Some) - .ok_or(Error::InvalidEntry), - redis::Value::SimpleString(value) => { - value.parse().map(Some).map_err(|_| Error::InvalidEntry) - } - _ => Err(Error::InvalidEntry), - } -} - -fn increment_with_floor( - connection: &mut ConnectionRef<'_>, - key: String, - amount: i64, - ttl: u64, -) -> Result { - redis::cmd("EVAL") - .arg(INCREMENT_WITH_FLOOR_SCRIPT) - .arg(1) - .arg(key) - .arg(amount) - .arg(ttl) - .query(connection) - .map_err(|_| Error::Unavailable) -} - -impl TtlCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - async fn async_get_ttl(&self, key: &str) -> Result, Error> { - RedisCache::async_get_ttl(self, key) - .await - .map(|ttl| ttl.map(|seconds| Duration::from_secs(seconds as u64))) - } -} - -impl ScanCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - async fn async_scan_iter(&self, pattern: &str, count: usize) -> Result, Error> { - RedisCache::async_scan_iter(self, pattern, count).await - } -} - -impl ClientInfoCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - type ClientList = String; - type Info = String; - - fn client_list(&self) -> Result { - RedisCache::client_list(self) - } - - fn info(&self) -> Result { - RedisCache::info(self) - } -} - -impl SetCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - type SetValue = RedisArg; - type SetResult = usize; - - async fn async_set_cache_sadd( - &self, - key: &str, - values: Vec, - ttl: Option, - ) -> Result { - RedisCache::async_set_cache_sadd(self, key, values, ttl).await - } -} - -impl QueueCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - type QueueValue = RedisArg; - type PopResult = RedisLpopResult; - - async fn async_rpush(&self, key: &str, values: Vec) -> Result { - RedisCache::async_rpush(self, key, values).await - } - - async fn async_lpop(&self, key: &str, count: Option) -> Result { - RedisCache::async_lpop(self, key, count).await - } -} - -impl ScriptCache for RedisCache -where - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - type Script = RedisScript; - - fn async_register_script(&self, source: String) -> Self::Script { - RedisScript { - connections: Arc::clone(&self.connections), - namespace: self.namespace.clone(), - source, - } - } -} diff --git a/litellm-rust/crates/cache-redis/src/claim.rs b/litellm-rust/crates/cache-redis/src/claim.rs new file mode 100644 index 00000000000..e4cbe8c8d5c --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/claim.rs @@ -0,0 +1,105 @@ +use litellm_cache::{CacheCodec, ClaimCache, Error, ExactCacheContext}; +use redis::Commands; + +use crate::{cache::RedisCache, connection::ConnectionRef}; + +const CLAIM_SCRIPT: &str = concat!( + "local current = redis.call('GET', KEYS[1]); ", + "if ARGV[1] == '' then if current ~= false and current ~= '' then return 0; end; ", + "elseif current ~= ARGV[1] then return 0; end; ", + "if ARGV[3] ~= '' then redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[2]); ", + "elseif ARGV[4] == '1' then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return 1" +); +const CLAIM_ATTEMPTS: usize = 8; + +fn stored_bytes(value: redis::Value) -> Result>, Error> { + match value { + redis::Value::Nil => Ok(None), + redis::Value::BulkString(bytes) => Ok(Some(bytes)), + redis::Value::SimpleString(text) => Ok(Some(text.into_bytes())), + _ => Err(Error::InvalidEntry), + } +} + +/// Eligibility is decided on decoded values, so a pin written by another encoder (Python's +/// `json.dumps` spacing or key order) still matches. The write is a compare-and-set on the +/// bytes that decision was made on, retried when another claimant wins the race. +fn claim( + connection: &mut ConnectionRef<'_>, + codec: &S, + key: &str, + candidate: S::Value, + eligible: &[S::Value], + ttl: u64, +) -> Result +where + S::Value: PartialEq, +{ + let payload = codec.encode(&candidate)?; + if payload.is_empty() { + return Err(Error::InvalidEntry); + } + for _ in 0..CLAIM_ATTEMPTS { + let current = stored_bytes( + connection + .get::<_, redis::Value>(key) + .map_err(|_| Error::Unavailable)?, + )? + .filter(|bytes| !bytes.is_empty()); + let existing = current + .as_deref() + .and_then(|bytes| codec.decode(bytes).ok()) + .filter(|existing| eligible.is_empty() || eligible.contains(existing)); + let refresh = existing + .as_ref() + .is_some_and(|existing| !eligible.is_empty() || *existing == candidate); + let write: &[u8] = if existing.is_some() { b"" } else { &payload }; + let applied = redis::cmd("EVAL") + .arg(CLAIM_SCRIPT) + .arg(1) + .arg(key) + .arg(current.as_deref().unwrap_or_default()) + .arg(ttl) + .arg(write) + .arg(u8::from(refresh)) + .query::(connection) + .map_err(|_| Error::Unavailable)?; + if applied { + return Ok(existing.unwrap_or(candidate)); + } + } + Err(Error::Unavailable) +} + +impl ClaimCache for RedisCache +where + S: CacheCodec + Clone + 'static, + S::Value: PartialEq, + C: redis::ConnectionLike + Send + 'static, +{ + fn claim_cache( + &self, + key: &str, + candidate: S::Value, + eligible: &[S::Value], + context: ExactCacheContext, + ) -> Result { + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(context.ttl); + self.execute(|connection| claim(connection, &self.codec, &key, candidate, eligible, ttl)) + } + + async fn async_claim_cache( + &self, + key: &str, + candidate: S::Value, + eligible: Vec, + context: ExactCacheContext, + ) -> Result { + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(context.ttl); + let codec = self.codec.clone(); + self.run(move |connection| claim(connection, &codec, &key, candidate, &eligible, ttl)) + .await + } +} diff --git a/litellm-rust/crates/cache-redis/src/cache/connection.rs b/litellm-rust/crates/cache-redis/src/connection.rs similarity index 65% rename from litellm-rust/crates/cache-redis/src/cache/connection.rs rename to litellm-rust/crates/cache-redis/src/connection.rs index 013bf055f89..2f58e2a9b80 100644 --- a/litellm-rust/crates/cache-redis/src/cache/connection.rs +++ b/litellm-rust/crates/cache-redis/src/connection.rs @@ -1,29 +1,126 @@ -use std::collections::HashMap; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; use litellm_cache::Error; use redis::{ - ConnectionAddr, ConnectionInfo, ConnectionLike, IntoConnectionInfo, - cluster::{ClusterClient, ClusterClientBuilder, ClusterConnection, NodeAddress}, + ConnectionAddr, ConnectionInfo, IntoConnectionInfo, + cluster::{ + ClusterClient, ClusterClientBuilder, ClusterConnection, ClusterPipeline, NodeAddress, + }, cluster_routing::{ - MultipleNodeRoutingInfo, ResponsePolicy, RoutingInfo, SingleNodeRoutingInfo, Slot, + MultipleNodeRoutingInfo, ResponsePolicy, RoutingInfo, SingleNodeRoutingInfo, }, }; -use super::REDIS_TIMEOUT; -use crate::topology::RedisNode; +use crate::topology::{RedisNode, RedisTopology}; -pub struct PooledConnection { - pub(super) connection: C, - pub(super) failed: bool, +pub(crate) const REDIS_TIMEOUT: Duration = Duration::from_secs(5); +const REDIS_POOL_SIZE: u32 = 16; + +#[allow(private_interfaces)] +pub enum Connections { + Pool(r2d2::Pool), + Cluster(r2d2::Pool), + Fixed(Mutex), +} + +impl Connections +where + C: redis::ConnectionLike + Send + 'static, +{ + pub fn execute( + &self, + operation: impl FnOnce(&mut ConnectionRef<'_>) -> Result, + ) -> Result { + match self { + Self::Pool(pool) => { + let mut pooled = pool.get().map_err(|_| Error::Unavailable)?; + let result = operation(&mut ConnectionRef::Node(&mut pooled.connection)); + pooled.failed = matches!(result, Err(Error::Unavailable)); + result + } + Self::Cluster(pool) => { + let mut pooled = pool.get().map_err(|_| Error::Unavailable)?; + let result = operation(&mut ConnectionRef::Cluster(&mut pooled.connection)); + pooled.failed = matches!(result, Err(Error::Unavailable)); + result + } + Self::Fixed(connection) => { + let mut connection = connection.lock().map_err(|_| Error::Unavailable)?; + operation(&mut ConnectionRef::Node(&mut *connection)) + } + } + } + + pub async fn run_blocking(connections: Arc, operation: F) -> Result + where + T: Send + 'static, + F: FnOnce(&mut ConnectionRef<'_>) -> Result + Send + 'static, + { + tokio::task::spawn_blocking(move || connections.execute(operation)) + .await + .map_err(|_| Error::Unavailable)? + } + + pub fn fixed(connection: C) -> Self { + Self::Fixed(Mutex::new(connection)) + } + + pub fn open(url: &str, topology: &RedisTopology) -> Result { + match topology { + RedisTopology::Standalone => Ok(Self::Pool(pool(ConnectionManager::open(url)?)?)), + RedisTopology::Cluster { startup_nodes } => Ok(Self::Cluster(pool( + ClusterConnectionManager::open(url, startup_nodes)?, + )?)), + } + } + + /// Closes every idle pooled connection; the next operation opens a fresh one. Connections + /// checked out right now return to the pool, and a caller-owned connection stays open. + pub fn disconnect(&self) { + match self { + Self::Pool(pool) => close_idle(pool), + Self::Cluster(pool) => close_idle(pool), + Self::Fixed(_) => {} + } + } +} + +fn pool(manager: M) -> Result, Error> { + r2d2::Pool::builder() + .max_size(REDIS_POOL_SIZE) + .min_idle(Some(0)) + .connection_timeout(REDIS_TIMEOUT) + .test_on_check_out(false) + .build(manager) + .map_err(|_| Error::Unavailable) +} + +fn close_idle(pool: &r2d2::Pool) +where + M: r2d2::ManageConnection>, +{ + let mut idle = Vec::new(); + while let Some(mut connection) = pool.try_get() { + connection.failed = true; + idle.push(connection); + } +} + +pub(crate) struct PooledConnection { + connection: C, + failed: bool, } /// Pools connections without a checkout PING, which would double every operation's round trips. /// A timed-out command leaves its reply on the socket while redis still reports the connection /// open, so any connection whose operation failed is discarded instead of being reused. -pub struct ConnectionManager(redis::Client); +pub(crate) struct ConnectionManager(redis::Client); impl ConnectionManager { - pub(super) fn open(url: &str) -> Result { + fn open(url: &str) -> Result { redis::Client::open(url) .map(Self) .map_err(|_| Error::Unavailable) @@ -54,10 +151,10 @@ impl r2d2::ManageConnection for ConnectionManager { } } -pub struct ClusterConnectionManager(ClusterClient); +pub(crate) struct ClusterConnectionManager(ClusterClient); impl ClusterConnectionManager { - pub(super) fn open(url: &str, startup_nodes: &[RedisNode]) -> Result { + fn open(url: &str, startup_nodes: &[RedisNode]) -> Result { if startup_nodes.is_empty() { return Err(Error::Unavailable); } @@ -172,43 +269,36 @@ impl redis::ConnectionLike for ConnectionRef<'_> { } impl ConnectionRef<'_> { - pub(crate) fn pipeline( + /// Runs `pipeline` and decodes its non-ignored replies as `T`. A cluster connection refuses + /// `Pipeline::query`, so there a transaction goes to its keys' slot as one MULTI/EXEC and + /// anything else is split per node by `ClusterPipeline`; either way the raw replies are + /// handed back to `pipeline` to decode. + pub(crate) fn query_pipeline( &mut self, - commands: Vec, - ) -> Result, Error> { + pipeline: &redis::Pipeline, + ) -> Result { match self { - Self::Node(connection) => { - let mut pipeline = redis::pipe(); - for command in &commands { - pipeline.add_command(command.clone()); - } - pipeline - .query::>(*connection) - .map_err(|_| Error::Unavailable) + Self::Node(connection) => pipeline.query(*connection), + Self::Cluster(connection) if pipeline.is_transaction() => { + redis::ConnectionLike::req_packed_commands( + *connection, + &pipeline.get_packed_pipeline(), + pipeline.len() + 1, + 1, + ) + .and_then(|replies| pipeline.query(&mut Replies(Some(replies)))) } Self::Cluster(connection) => { - let mut replies: Vec> = vec![None; commands.len()]; - for indices in slot_groups(&commands).into_values() { - let mut pipeline = redis::pipe(); - for index in &indices { - pipeline.add_command(commands[*index].clone()); - } - let values = connection - .req_packed_commands(&pipeline.get_packed_pipeline(), 0, indices.len()) - .map_err(|_| Error::Unavailable)?; - if values.len() != indices.len() { - return Err(Error::Unavailable); - } - for (index, value) in indices.into_iter().zip(values) { - replies[index] = Some(value); - } + let mut cluster = ClusterPipeline::with_capacity(pipeline.len()); + for command in pipeline.cmd_iter() { + cluster.add_command(command.clone()); } - replies - .into_iter() - .collect::>>() - .ok_or(Error::Unavailable) + cluster + .query(connection) + .and_then(|replies| pipeline.query(&mut Replies(Some(replies)))) } } + .map_err(|_| Error::Unavailable) } pub(crate) fn scan( @@ -379,14 +469,35 @@ fn scan_command(cursor: u64, pattern: &str, count: usize) -> redis::Cmd { command } -fn slot_groups(commands: &[redis::Cmd]) -> HashMap> { - let mut groups: HashMap> = HashMap::new(); - for (index, command) in commands.iter().enumerate() { - let key = match command.args_iter().nth(1) { - Some(redis::Arg::Simple(key)) => key, - _ => b"", - }; - groups.entry(Slot::for_key(key)).or_default().push(index); +/// Hands already received pipeline replies to `Pipeline::query`, so it applies its own +/// ignore and error handling to replies a cluster pipeline gathered from several nodes. +struct Replies(Option>); + +impl redis::ConnectionLike for Replies { + fn req_packed_command(&mut self, _: &[u8]) -> redis::RedisResult { + Err((redis::ErrorKind::Client, "replies hold a pipeline only").into()) + } + + fn req_packed_commands( + &mut self, + _: &[u8], + _: usize, + _: usize, + ) -> redis::RedisResult> { + self.0 + .take() + .ok_or_else(|| (redis::ErrorKind::Client, "replies were already read").into()) + } + + fn get_db(&self) -> i64 { + 0 + } + + fn check_connection(&mut self) -> bool { + true + } + + fn is_open(&self) -> bool { + true } - groups } diff --git a/litellm-rust/crates/cache-redis/src/counter.rs b/litellm-rust/crates/cache-redis/src/counter.rs new file mode 100644 index 00000000000..adcd597b2f4 --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/counter.rs @@ -0,0 +1,205 @@ +use std::time::Duration; + +use litellm_cache::{ + BoundedCounterCache, CacheCodec, CountReadCache, CounterCache, Error, ExactCacheContext, + IncrementOperation, +}; + +use crate::{ + cache::{RedisCache, ttl_seconds}, + connection::ConnectionRef, + store::mget, +}; + +const INCREMENT_SCRIPT: &str = concat!( + "local value = redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]); ", + "if redis.call('TTL', KEYS[1]) == -1 then ", + "redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return value" +); +const INCREMENT_WITH_FLOOR_SCRIPT: &str = concat!( + "local count = redis.call('INCRBY', KEYS[1], ARGV[1]); ", + "if count < 0 then count = redis.call('INCRBY', KEYS[1], -count); end; ", + "if redis.call('TTL', KEYS[1]) < 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; ", + "return count" +); +const SET_MAX_SCRIPT: &str = concat!( + "local current = redis.call('GET', KEYS[1]); ", + "if current == false or tonumber(current) < tonumber(ARGV[1]) then ", + "redis.call('SET', KEYS[1], ARGV[1]); ", + "if tonumber(ARGV[2]) > 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; ", + "return ARGV[1]; end; return current" +); + +impl CounterCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn increment_cache( + &self, + key: &str, + amount: f64, + context: ExactCacheContext, + ) -> Result { + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(context.ttl); + self.execute(|connection| increment(connection, key, amount, ttl, false)) + } + + /// Python `_incrbyfloat_with_ttl`: without `refresh_ttl` the TTL is set only on a key that + /// has none, in one atomic script; with it, every increment re-arms the TTL. + async fn async_increment( + &self, + key: &str, + amount: f64, + context: ExactCacheContext, + refresh_ttl: bool, + ) -> Result { + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(context.ttl); + self.run(move |connection| increment(connection, key, amount, ttl, refresh_ttl)) + .await + } + + async fn async_increment_pipeline( + &self, + operations: Vec, + ) -> Result, Error> { + if operations.is_empty() { + return Ok(Vec::new()); + } + let mut pipeline = redis::pipe(); + for operation in operations { + let key = self.namespaced_key(&operation.key); + pipeline.cmd("INCRBYFLOAT").arg(&key).arg(operation.amount); + if let Some(ttl) = operation.ttl { + pipeline + .cmd("EXPIRE") + .arg(key) + .arg(ttl_seconds(ttl)) + .ignore(); + } + } + self.run(move |connection| connection.query_pipeline(&pipeline)) + .await + } +} + +fn increment( + connection: &mut ConnectionRef<'_>, + key: String, + amount: f64, + ttl: u64, + refresh_ttl: bool, +) -> Result { + if !refresh_ttl { + return redis::cmd("EVAL") + .arg(INCREMENT_SCRIPT) + .arg(1) + .arg(key) + .arg(amount) + .arg(ttl) + .query(connection) + .map_err(|_| Error::Unavailable); + } + connection + .query_pipeline( + redis::pipe() + .cmd("INCRBYFLOAT") + .arg(&key) + .arg(amount) + .cmd("EXPIRE") + .arg(&key) + .arg(ttl) + .ignore(), + ) + .map(|(value,)| value) +} + +impl CountReadCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn batch_get_counts(&self, keys: &[String]) -> Result>, Error> { + let keys = self.namespaced_keys(keys); + self.execute(|connection| mget(connection, keys))? + .into_iter() + .map(count) + .collect() + } + + async fn async_batch_get_counts(&self, keys: Vec) -> Result>, Error> { + let keys = self.namespaced_keys(&keys); + self.run(move |connection| mget(connection, keys)) + .await? + .into_iter() + .map(count) + .collect() + } +} + +fn count(value: redis::Value) -> Result, Error> { + redis::from_redis_value(value).map_err(|_| Error::InvalidEntry) +} + +impl BoundedCounterCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn increment_with_floor(&self, key: &str, amount: i64, ttl: Duration) -> Result { + let key = self.namespaced_key(key); + let ttl = ttl_seconds(ttl); + self.execute(|connection| increment_with_floor(connection, key, amount, ttl)) + } + + async fn async_increment_with_floor( + &self, + key: &str, + amount: i64, + ttl: Duration, + ) -> Result { + let key = self.namespaced_key(key); + let ttl = ttl_seconds(ttl); + self.run(move |connection| increment_with_floor(connection, key, amount, ttl)) + .await + } + + async fn async_set_max( + &self, + key: &str, + value: f64, + ttl: Option, + ) -> Result { + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(ttl); + self.run(move |connection| { + redis::cmd("EVAL") + .arg(SET_MAX_SCRIPT) + .arg(1) + .arg(key) + .arg(value) + .arg(ttl) + .query(connection) + .map_err(|_| Error::Unavailable) + }) + .await + } +} + +fn increment_with_floor( + connection: &mut ConnectionRef<'_>, + key: String, + amount: i64, + ttl: u64, +) -> Result { + redis::cmd("EVAL") + .arg(INCREMENT_WITH_FLOOR_SCRIPT) + .arg(1) + .arg(key) + .arg(amount) + .arg(ttl) + .query(connection) + .map_err(|_| Error::Unavailable) +} diff --git a/litellm-rust/crates/cache-redis/src/keys.rs b/litellm-rust/crates/cache-redis/src/keys.rs new file mode 100644 index 00000000000..b9d7bb20ff3 --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/keys.rs @@ -0,0 +1,63 @@ +use std::time::Duration; + +use litellm_cache::{CacheCodec, Error, RefreshTtlCache, ScanCache, TtlCache}; + +use crate::cache::RedisCache; + +impl TtlCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + async fn async_get_ttl(&self, key: &str) -> Result, Error> { + let key = self.namespaced_key(key); + let ttl = self + .run(move |connection| { + redis::cmd("TTL") + .arg(key) + .query::(connection) + .map_err(|_| Error::Unavailable) + }) + .await?; + Ok(u64::try_from(ttl).ok().map(Duration::from_secs)) + } +} + +impl RefreshTtlCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + async fn async_refresh_ttl(&self, key: &str, ttl: Option) -> Result { + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(ttl); + self.run(move |connection| { + redis::cmd("EXPIRE") + .arg(key) + .arg(ttl) + .query(connection) + .map_err(|_| Error::Unavailable) + }) + .await + } +} + +impl ScanCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + async fn async_scan_iter(&self, pattern: &str, count: usize) -> Result, Error> { + let pattern = format!("{}*", self.namespaced_key(pattern)); + self.run(move |connection| { + let mut matches = Vec::new(); + connection.scan(&pattern, count, |_, keys| { + matches.extend(keys); + Ok(matches.len() < count) + })?; + matches.truncate(count); + Ok(matches) + }) + .await + } +} diff --git a/litellm-rust/crates/cache-redis/src/lib.rs b/litellm-rust/crates/cache-redis/src/lib.rs index efb0db931ac..037e39e5d40 100644 --- a/litellm-rust/crates/cache-redis/src/lib.rs +++ b/litellm-rust/crates/cache-redis/src/lib.rs @@ -1,11 +1,15 @@ mod cache; +mod claim; +pub mod connection; +mod counter; +mod keys; +mod lifecycle; +mod queue; +mod script; +mod store; mod topology; -pub mod connection { - pub use crate::cache::{ConnectionRef, Connections}; -} - -pub use cache::{ - RedisArg, RedisCache, RedisLpopOperation, RedisLpopResult, RedisRpushOperation, RedisScript, -}; +pub use cache::RedisCache; +pub use queue::{RedisLpopOperation, RedisLpopResult, RedisRpushOperation}; +pub use script::{RedisArg, RedisScript}; pub use topology::{RedisNode, RedisTopology}; diff --git a/litellm-rust/crates/cache-redis/src/lifecycle.rs b/litellm-rust/crates/cache-redis/src/lifecycle.rs new file mode 100644 index 00000000000..6ab8c31509e --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/lifecycle.rs @@ -0,0 +1,85 @@ +use litellm_cache::{ + CacheCodec, CacheConnectionResult, CacheConnectionStatus, ClientInfoCache, ConnectionCache, + DisconnectCache, Error, PingCache, +}; + +use crate::{cache::RedisCache, topology::RedisTopology}; + +impl PingCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn sync_ping(&self) -> Result { + self.execute(|connection| connection.ping().map_err(|_| Error::Unavailable)) + } + + async fn ping(&self) -> Result { + self.run(|connection| connection.ping().map_err(|_| Error::Unavailable)) + .await + } +} + +impl ConnectionCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + /// Python `RedisCache.test_connection`, or `RedisClusterCache.test_connection` for a + /// cluster topology, which differs only in its messages. + async fn test_connection(&self) -> Result { + let label = match self.topology { + RedisTopology::Standalone => "Redis", + RedisTopology::Cluster { .. } => "Redis Cluster", + }; + let ping = self + .run(|connection| Ok(connection.ping().map_err(|error| error.to_string()))) + .await + .unwrap_or_else(|error| Err(error.to_string())); + Ok(match ping { + Ok(true) => CacheConnectionResult { + status: CacheConnectionStatus::Success, + message: format!("{label} connection test successful"), + error: None, + }, + Ok(false) => CacheConnectionResult { + status: CacheConnectionStatus::Failed, + message: format!("{label} ping returned False"), + error: None, + }, + Err(error) => CacheConnectionResult { + status: CacheConnectionStatus::Failed, + message: format!("{label} connection failed: {error}"), + error: Some(error), + }, + }) + } +} + +impl DisconnectCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + async fn disconnect(&self) -> Result<(), Error> { + self.connections.disconnect(); + Ok(()) + } +} + +impl ClientInfoCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + type ClientList = String; + type Info = String; + + fn client_list(&self) -> Result { + self.execute(|connection| connection.node_text(redis::cmd("CLIENT").arg("LIST"))) + } + + fn info(&self) -> Result { + self.execute(|connection| connection.node_text(&redis::cmd("INFO"))) + } +} diff --git a/litellm-rust/crates/cache-redis/src/queue.rs b/litellm-rust/crates/cache-redis/src/queue.rs new file mode 100644 index 00000000000..623bce23640 --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/queue.rs @@ -0,0 +1,226 @@ +use std::time::Duration; + +use litellm_cache::{CacheCodec, Error, PopOperation, PushOperation, QueueCache, SetCache}; + +use crate::{cache::RedisCache, script::RedisArg}; + +pub type RedisRpushOperation = PushOperation; +pub type RedisLpopOperation = PopOperation; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RedisLpopResult { + Missing, + Value(Vec), + Values(Vec>), +} + +impl SetCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + type SetValue = RedisArg; + type SetResult = usize; + + async fn async_set_cache_sadd( + &self, + key: &str, + values: Vec, + ttl: Option, + ) -> Result { + if values.is_empty() { + return Err(Error::InvalidEntry); + } + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(ttl); + let mut pipeline = redis::pipe(); + pipeline + .cmd("SADD") + .arg(&key) + .arg(values) + .cmd("EXPIRE") + .arg(&key) + .arg(ttl) + .ignore(); + self.run(move |connection| connection.query_pipeline(&pipeline)) + .await + .map(|(added,)| added) + } +} + +impl QueueCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + type QueueValue = RedisArg; + type PopResult = RedisLpopResult; + + async fn async_rpush(&self, key: &str, values: Vec) -> Result { + if values.is_empty() { + return Err(Error::InvalidEntry); + } + let key = self.namespaced_key(key); + self.run(move |connection| { + redis::cmd("RPUSH") + .arg(key) + .arg(values) + .query(connection) + .map_err(|_| Error::Unavailable) + }) + .await + } + + async fn async_rpush_and_trim( + &self, + key: &str, + values: Vec, + max_len: usize, + ) -> Result { + if values.is_empty() { + return Err(Error::InvalidEntry); + } + let key = self.namespaced_key(key); + let start = i64::try_from(max_len).map_or(i64::MIN, |max_len| -max_len); + let mut pipeline = redis::pipe(); + pipeline + .atomic() + .cmd("RPUSH") + .arg(&key) + .arg(values) + .cmd("LTRIM") + .arg(&key) + .arg(start) + .arg(-1) + .ignore(); + self.run(move |connection| connection.query_pipeline(&pipeline)) + .await + .map(|(length,)| length) + } + + async fn async_rpush_pipeline( + &self, + operations: Vec, + ) -> Result, Error> { + if operations.is_empty() { + return Ok(Vec::new()); + } + let mut pipeline = redis::pipe(); + for operation in operations { + if operation.values.is_empty() { + return Err(Error::InvalidEntry); + } + pipeline + .cmd("RPUSH") + .arg(self.namespaced_key(&operation.key)) + .arg(operation.values); + } + self.run(move |connection| connection.query_pipeline(&pipeline)) + .await + } + + async fn async_lpop(&self, key: &str, count: Option) -> Result { + if let Some(count) = count + && self.major_version().await < 7 + { + return self.lpop_one_at_a_time(key, count).await; + } + let command = lpop(self.namespaced_key(key), count); + let value = self + .run(move |connection| { + command + .query::(connection) + .map_err(|_| Error::Unavailable) + }) + .await?; + lpop_result(value, count.is_some()) + } + + async fn async_lpop_pipeline( + &self, + operations: Vec, + ) -> Result, Error> { + if operations.is_empty() { + return Ok(Vec::new()); + } + if operations.iter().any(|operation| operation.count.is_some()) + && self.major_version().await < 7 + { + let mut results = Vec::with_capacity(operations.len()); + for operation in &operations { + results.push(self.async_lpop(&operation.key, operation.count).await?); + } + return Ok(results); + } + let multiple = operations + .iter() + .map(|operation| operation.count.is_some()) + .collect::>(); + let mut pipeline = redis::pipe(); + for operation in operations { + pipeline.add_command(lpop(self.namespaced_key(&operation.key), operation.count)); + } + self.run(move |connection| connection.query_pipeline::>(&pipeline)) + .await? + .into_iter() + .zip(multiple) + .map(|(value, multiple)| lpop_result(value, multiple)) + .collect() + } +} + +fn lpop(key: String, count: Option) -> redis::Cmd { + let mut command = redis::cmd("LPOP"); + command.arg(key); + if let Some(count) = count { + command.arg(count); + } + command +} + +fn redis_bytes(value: redis::Value) -> Result, Error> { + match value { + redis::Value::BulkString(bytes) => Ok(bytes), + redis::Value::SimpleString(text) => Ok(text.into_bytes()), + _ => Err(Error::InvalidEntry), + } +} + +impl RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + /// `handle_lpop_count_for_older_redis_versions`: `count` single-`LPOP` pipelines, keeping + /// only the values actually popped. + async fn lpop_one_at_a_time(&self, key: &str, count: usize) -> Result { + let key = self.namespaced_key(key); + let mut values = Vec::new(); + for _ in 0..count { + let mut pipeline = redis::pipe(); + pipeline.add_command(lpop(key.clone(), None)); + let replies = self + .run(move |connection| connection.query_pipeline::>(&pipeline)) + .await?; + for reply in replies { + if reply != redis::Value::Nil { + values.push(redis_bytes(reply)?); + } + } + } + Ok(RedisLpopResult::Values(values)) + } +} + +fn lpop_result(value: redis::Value, multiple: bool) -> Result { + match value { + redis::Value::Nil => Ok(RedisLpopResult::Missing), + redis::Value::Array(values) if multiple => values + .into_iter() + .map(redis_bytes) + .collect::, _>>() + .map(RedisLpopResult::Values), + value if !multiple => redis_bytes(value).map(RedisLpopResult::Value), + _ => Err(Error::InvalidEntry), + } +} diff --git a/litellm-rust/crates/cache-redis/src/script.rs b/litellm-rust/crates/cache-redis/src/script.rs new file mode 100644 index 00000000000..e8a16bcc2b5 --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/script.rs @@ -0,0 +1,136 @@ +use std::sync::Arc; + +use litellm_cache::{CacheCodec, CacheScript, Error, ScriptCache}; + +use crate::{ + cache::{RedisCache, namespaced_key}, + connection::Connections, +}; + +#[derive(Clone, Debug, PartialEq)] +pub enum RedisArg { + Bytes(Vec), + Integer(i64), + Float(f64), +} + +impl From<&str> for RedisArg { + fn from(value: &str) -> Self { + Self::Bytes(value.as_bytes().to_vec()) + } +} + +impl From for RedisArg { + fn from(value: String) -> Self { + Self::Bytes(value.into_bytes()) + } +} + +impl From> for RedisArg { + fn from(value: Vec) -> Self { + Self::Bytes(value) + } +} + +impl From for RedisArg { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for RedisArg { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl redis::ToRedisArgs for RedisArg { + fn write_redis_args(&self, out: &mut W) + where + W: ?Sized + redis::RedisWrite, + { + match self { + Self::Bytes(value) => value.write_redis_args(out), + Self::Integer(value) => value.write_redis_args(out), + Self::Float(value) => value.write_redis_args(out), + } + } +} + +pub struct RedisScript { + connections: Arc>, + namespace: Option, + source: String, +} + +impl CacheScript for RedisScript +where + C: redis::ConnectionLike + Send + 'static, +{ + type Argument = RedisArg; + type Output = redis::Value; + + async fn invoke( + &self, + keys: Vec, + arguments: Vec, + ) -> Result { + let keys = keys + .into_iter() + .map(|key| namespaced_key(self.namespace.as_deref(), &key)) + .collect::>(); + let source = self.source.clone(); + Connections::run_blocking(Arc::clone(&self.connections), move |connection| { + eval(connection, &source, keys, arguments) + }) + .await + } +} + +impl RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + pub async fn async_eval( + &self, + script: String, + keys: Vec, + arguments: Vec, + ) -> Result { + let keys = self.namespaced_keys(&keys); + self.run(move |connection| eval(connection, &script, keys, arguments)) + .await + } +} + +fn eval( + connection: &mut impl redis::ConnectionLike, + script: &str, + keys: Vec, + arguments: Vec, +) -> Result { + redis::cmd("EVAL") + .arg(script) + .arg(keys.len()) + .arg(keys) + .arg(arguments) + .query(connection) + .map_err(|_| Error::Unavailable) +} + +impl ScriptCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + type Script = RedisScript; + + fn async_register_script(&self, source: String) -> Self::Script { + RedisScript { + connections: Arc::clone(&self.connections), + namespace: self.namespace.clone(), + source, + } + } +} diff --git a/litellm-rust/crates/cache-redis/src/store.rs b/litellm-rust/crates/cache-redis/src/store.rs new file mode 100644 index 00000000000..111d481ad02 --- /dev/null +++ b/litellm-rust/crates/cache-redis/src/store.rs @@ -0,0 +1,232 @@ +use std::time::Duration; + +use litellm_cache::{ + BaseCache, BatchCache, BatchEntry, BulkDeleteCache, CacheCodec, DeleteCache, Error, + ExactCacheContext, FlushAllCache, FlushCache, TtlPipelineCache, +}; +use redis::Commands; + +use crate::{cache::RedisCache, connection::ConnectionRef}; + +impl BaseCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + type Value = S::Value; + type Context = ExactCacheContext; + + fn get_ttl(&self, context: &Self::Context) -> Option { + context.ttl.or(Some(self.default_ttl)) + } + + fn set_cache( + &self, + key: &str, + value: Self::Value, + context: &ExactCacheContext, + ) -> Result<(), Error> { + let payload = self.codec.encode(&value)?; + let ttl = self.ttl_or_default(context.ttl); + let key = self.namespaced_key(key); + self.execute(|connection| { + connection + .set_ex::<_, _, ()>(key, payload, ttl) + .map_err(|_| Error::Unavailable) + }) + } + + fn get_cache(&self, key: &str, _: &ExactCacheContext) -> Result, Error> { + let key = self.namespaced_key(key); + let value = self.execute(|connection| { + connection + .get::<_, redis::Value>(key) + .map_err(|_| Error::Unavailable) + })?; + self.decode_response(value) + } + + async fn async_set_cache( + &self, + key: &str, + value: Self::Value, + context: ExactCacheContext, + ) -> Result<(), Error> { + let payload = self.codec.encode(&value)?; + let key = self.namespaced_key(key); + let ttl = self.ttl_or_default(context.ttl); + self.run(move |connection| { + connection + .set_ex::<_, _, ()>(key, payload, ttl) + .map_err(|_| Error::Unavailable) + }) + .await + } + + async fn async_get_cache( + &self, + key: &str, + _: &ExactCacheContext, + ) -> Result, Error> { + let key = self.namespaced_key(key); + let value = self + .run(move |connection| { + connection + .get::<_, redis::Value>(key) + .map_err(|_| Error::Unavailable) + }) + .await?; + self.decode_response(value) + } + + async fn async_set_cache_pipeline( + &self, + cache_list: Vec<(String, Self::Value)>, + context: ExactCacheContext, + ) -> Result<(), Error> { + self.async_set_cache_pipeline_with_ttls( + cache_list + .into_iter() + .map(|(key, value)| (key, value, context.ttl)) + .collect(), + ) + .await + } +} + +impl TtlPipelineCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + async fn async_set_cache_pipeline_with_ttls( + &self, + entries: Vec<(String, Self::Value, Option)>, + ) -> Result<(), Error> { + if entries.is_empty() { + return Ok(()); + } + let mut pipeline = redis::pipe(); + for (key, value, ttl) in entries { + pipeline + .cmd("SETEX") + .arg(self.namespaced_key(&key)) + .arg(self.ttl_or_default(ttl)) + .arg(self.codec.encode(&value)?) + .ignore(); + } + self.run(move |connection| connection.query_pipeline(&pipeline)) + .await + } +} + +impl BatchCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn batch_get_cache( + &self, + keys: &[String], + _: &ExactCacheContext, + ) -> Result>, Error> { + let keys = self.namespaced_keys(keys); + self.execute(|connection| mget(connection, keys))? + .into_iter() + .map(|value| self.decode_batch_response(value)) + .collect() + } + + async fn async_batch_get_cache( + &self, + keys: Vec, + _: ExactCacheContext, + ) -> Result>, Error> { + let keys = self.namespaced_keys(&keys); + self.run(move |connection| mget(connection, keys)) + .await? + .into_iter() + .map(|value| self.decode_batch_response(value)) + .collect() + } +} + +pub(crate) fn mget( + connection: &mut ConnectionRef<'_>, + keys: Vec, +) -> Result, Error> { + redis::cmd("MGET") + .arg(keys) + .query(connection) + .map_err(|_| Error::Unavailable) +} + +impl DeleteCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn delete_cache(&self, key: &str) -> Result<(), Error> { + let key = self.namespaced_key(key); + self.execute(|connection| connection.del::<_, ()>(key).map_err(|_| Error::Unavailable)) + } + + async fn async_delete_cache(&self, key: &str) -> Result<(), Error> { + let key = self.namespaced_key(key); + self.run(move |connection| connection.del::<_, ()>(key).map_err(|_| Error::Unavailable)) + .await + } +} + +impl BulkDeleteCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + async fn delete_cache_keys(&self, keys: Vec) -> Result { + if keys.is_empty() { + return Ok(0); + } + let keys = self.namespaced_keys(&keys); + self.run(move |connection| connection.del(keys).map_err(|_| Error::Unavailable)) + .await + } +} + +impl FlushCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn flush_cache(&self) -> Result<(), Error> { + let pattern = self.namespaced_pattern()?; + self.execute(|connection| flush_matching(connection, &pattern)) + } + + async fn async_flush_cache(&self) -> Result<(), Error> { + let pattern = self.namespaced_pattern()?; + self.run(move |connection| flush_matching(connection, &pattern)) + .await + } +} + +impl FlushAllCache for RedisCache +where + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn flushall(&self) -> Result<(), Error> { + self.execute(|connection| connection.flushall()) + } +} + +fn flush_matching(connection: &mut ConnectionRef<'_>, pattern: &str) -> Result<(), Error> { + connection.scan(pattern, 1000, |connection, keys| { + if !keys.is_empty() { + connection + .del::<_, usize>(keys) + .map_err(|_| Error::Unavailable)?; + } + Ok(true) + }) +} diff --git a/litellm-rust/crates/cache-redis/tests/cache.rs b/litellm-rust/crates/cache-redis/tests/cache.rs index 337f27984f8..70baeffd572 100644 --- a/litellm-rust/crates/cache-redis/tests/cache.rs +++ b/litellm-rust/crates/cache-redis/tests/cache.rs @@ -1,43 +1,84 @@ +mod support; + use std::time::Duration; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheCodec, CacheConnectionStatus, CacheScript, ClaimCache, - CounterCache, DeleteCache, Error, ExactCacheContext, FlushCache, IncrementOperation, JsonCodec, - ScriptCache, get_cache, set_cache, + BaseCache, BatchCache, BatchEntry, BoundedCounterCache, BulkDeleteCache, CacheCodec, + CacheConnectionStatus, CacheScript, ClaimCache, ClientInfoCache, ConnectionCache, + CountReadCache, CounterCache, DeleteCache, DisconnectCache, Error, ExactCacheContext, + FlushAllCache, FlushCache, IncrementOperation, JsonCodec, PingCache, QueueCache, + RefreshTtlCache, ScanCache, ScriptCache, SetCache, TtlCache, TtlPipelineCache, get_cache, + set_cache, }; use litellm_cache_redis::{ RedisArg, RedisCache, RedisLpopOperation, RedisLpopResult, RedisRpushOperation, }; use redis_test::{MockCmd, MockRedisConnection}; +use rstest::{fixture, rstest}; +use serde_json::json; +use support::TaggedByteCodec; -struct TaggedByteCodec(u8); +type Mocked = RedisCache; -impl CacheCodec for TaggedByteCodec { - type Value = u8; - - fn encode(&self, value: &u8) -> Result, Error> { - if *value > 127 { - return Err(Error::InvalidEntry); - } - Ok(vec![self.0, *value]) - } - - fn decode(&self, bytes: &[u8]) -> Result { - match bytes { - [tag, value] if *tag == self.0 => Ok(*value), - _ => Err(Error::InvalidEntry), - } - } +fn mock(commands: Vec) -> MockRedisConnection { + MockRedisConnection::new(commands).assert_all_commands_consumed() } -#[test] +fn tagged(commands: Vec) -> Mocked { + RedisCache::with_connection(mock(commands), None, TaggedByteCodec(42)) +} + +fn json_cache(commands: Vec) -> Mocked> { + RedisCache::with_connection(mock(commands), None, JsonCodec::new()) +} + +fn team(commands: Vec) -> Mocked> { + json_cache(commands).with_namespace(Some("team".into())) +} + +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext::default() +} + +fn scan(pattern: &str, cursor: u64, count: usize, reply: redis::Value) -> MockCmd { + MockCmd::new( + redis::cmd("SCAN") + .cursor_arg(cursor) + .arg("MATCH") + .arg(pattern) + .arg("COUNT") + .arg(count), + Ok(reply), + ) +} + +#[rstest] fn constructor_rejects_invalid_urls() { assert!(RedisCache::new("not a redis url", None, JsonCodec::::new()).is_err()); } -#[test] +#[rstest] +#[case::zero_rounds_up_to_one(Some(Duration::ZERO), 1)] +#[case::fractions_round_up(Some(Duration::from_millis(1500)), 2)] +#[case::whole_seconds_are_kept(Some(Duration::from_secs(15)), 15)] +#[case::missing_ttl_uses_default(None, 600)] +fn writes_round_ttls_up_to_positive_seconds(#[case] ttl: Option, #[case] seconds: u64) { + let cache = tagged(vec![MockCmd::new( + redis::cmd("SETEX") + .arg("key") + .arg(seconds) + .arg([42u8, 7].as_slice()), + Ok("OK"), + )]); + cache + .set_cache("key", 7, &ExactCacheContext { ttl }) + .unwrap(); +} + +#[rstest] fn generic_helpers_use_the_injected_codec_and_ttl() { - let connection = MockRedisConnection::new([ + let cache = tagged(vec![ MockCmd::new( redis::cmd("SETEX") .arg("counter") @@ -46,9 +87,7 @@ fn generic_helpers_use_the_injected_codec_and_ttl() { Ok("OK"), ), MockCmd::new(redis::cmd("GET").arg("counter"), Ok(vec![42u8, 7])), - ]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, TaggedByteCodec(42)); + ]); let context = ExactCacheContext { ttl: Some(Duration::from_millis(1500)), }; @@ -56,34 +95,56 @@ fn generic_helpers_use_the_injected_codec_and_ttl() { assert_eq!(get_cache(&cache, "counter", &context).unwrap(), Some(7)); } -#[tokio::test] -async fn async_operations_preserve_codec_ttl_and_missing_values() { - let connection = MockRedisConnection::new([ +#[rstest] +fn commands_round_trip_entries_and_delete_only_namespaced_keys(context: ExactCacheContext) { + let value = json!({"deployment": "model-a", "cooldown_seconds": 30}); + let payload = JsonCodec::::new() + .encode(&value) + .unwrap(); + let cache = json_cache(vec![ MockCmd::new( redis::cmd("SETEX") - .arg("counter") - .arg(9) - .arg([42u8, 7].as_slice()), + .arg("litellm-cache:key") + .arg(600) + .arg(payload.clone()), Ok("OK"), ), - MockCmd::new(redis::cmd("GET").arg("counter"), Ok(vec![42u8, 7])), - MockCmd::new( - redis::cmd("SETEX") - .arg("batch") - .arg(2) - .arg([42u8, 8].as_slice()), - Ok("OK"), - ), - MockCmd::new(redis::cmd("DEL").arg("counter"), Ok(1u32)), - MockCmd::new(redis::cmd("GET").arg("counter"), Ok(redis::Value::Nil)), + MockCmd::new(redis::cmd("GET").arg("litellm-cache:key"), Ok(payload)), + MockCmd::new(redis::cmd("DEL").arg("litellm-cache:key"), Ok(1u32)), ]) - .assert_all_commands_consumed(); + .with_namespace(Some("litellm-cache".into())); + + cache.set_cache("key", value.clone(), &context).unwrap(); + assert_eq!(cache.get_cache("key", &context).unwrap(), Some(value)); + cache.delete_cache("key").unwrap(); +} + +#[rstest] +#[tokio::test] +async fn async_operations_preserve_codec_ttl_and_missing_values(context: ExactCacheContext) { let cache = RedisCache::with_connection( - connection, + mock(vec![ + MockCmd::new( + redis::cmd("SETEX") + .arg("counter") + .arg(9) + .arg([42u8, 7].as_slice()), + Ok("OK"), + ), + MockCmd::new(redis::cmd("GET").arg("counter"), Ok(vec![42u8, 7])), + MockCmd::new( + redis::cmd("SETEX") + .arg("batch") + .arg(2) + .arg([42u8, 8].as_slice()), + Ok("OK"), + ), + MockCmd::new(redis::cmd("DEL").arg("counter"), Ok(1u32)), + MockCmd::new(redis::cmd("GET").arg("counter"), Ok(redis::Value::Nil)), + ]), Some(Duration::from_secs(9)), TaggedByteCodec(42), ); - let context = ExactCacheContext::default(); cache .batch_cache_write("counter", 7, context.clone()) .await @@ -108,15 +169,13 @@ async fn async_operations_preserve_codec_ttl_and_missing_values() { ); } +#[rstest] #[tokio::test] -async fn codec_errors_propagate_without_writing_partial_batches() { - let connection = MockRedisConnection::new([ +async fn codec_errors_propagate_without_writing_partial_batches(context: ExactCacheContext) { + let cache = tagged(vec![ MockCmd::new(redis::cmd("GET").arg("invalid"), Ok(vec![99u8, 7])), MockCmd::new(redis::cmd("GET").arg("invalid"), Ok(vec![99u8, 7])), - ]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, TaggedByteCodec(42)); - let context = ExactCacheContext::default(); + ]); assert_eq!( cache.set_cache("invalid", 255, &context), Err(Error::InvalidEntry) @@ -134,6 +193,15 @@ async fn codec_errors_propagate_without_writing_partial_batches() { .await, Err(Error::InvalidEntry) ); + assert_eq!( + cache + .async_set_cache_pipeline_with_ttls(vec![ + ("valid".into(), 7, None), + ("invalid".into(), 255, None), + ]) + .await, + Err(Error::InvalidEntry) + ); assert_eq!( cache.get_cache("invalid", &context), Err(Error::InvalidEntry) @@ -144,221 +212,269 @@ async fn codec_errors_propagate_without_writing_partial_batches() { ); } -#[test] -fn namespaces_are_optional_and_existing_prefixes_are_not_duplicated() { - let connection = MockRedisConnection::new([ - MockCmd::new(redis::cmd("GET").arg("team:key"), Ok(redis::Value::Nil)), - MockCmd::new(redis::cmd("GET").arg("team:key"), Ok(redis::Value::Nil)), - ]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team".into())); - assert_eq!( - cache - .get_cache("key", &ExactCacheContext::default()) - .unwrap(), - None - ); - assert_eq!( - cache - .get_cache("team:key", &ExactCacheContext::default()) - .unwrap(), - None - ); +#[rstest] +#[case::bare_key("key")] +#[case::already_prefixed("team:key")] +fn namespaces_are_added_once(#[case] key: &str, context: ExactCacheContext) { + let cache = team(vec![MockCmd::new( + redis::cmd("GET").arg("team:key"), + Ok(redis::Value::Nil), + )]); + assert_eq!(cache.get_cache(key, &context).unwrap(), None); } -#[test] -fn flush_requires_a_namespace_and_escapes_glob_metacharacters() { - let unscoped = RedisCache::with_connection( - MockRedisConnection::new([]).assert_all_commands_consumed(), - None, - JsonCodec::::new(), - ); +#[rstest] +#[case::empty(Some(String::new()))] +#[case::missing(None)] +fn empty_namespaces_leave_keys_unprefixed( + #[case] namespace: Option, + context: ExactCacheContext, +) { + let cache = json_cache(vec![MockCmd::new( + redis::cmd("GET").arg("key"), + Ok(redis::Value::Nil), + )]) + .with_namespace(namespace); + assert_eq!(cache.namespace(), None); + assert_eq!(cache.get_cache("key", &context).unwrap(), None); +} + +#[rstest] +#[tokio::test] +async fn flush_requires_a_namespace() { + let unscoped = json_cache(Vec::new()); assert_eq!(unscoped.flush_cache(), Err(Error::UnscopedFlush)); - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("SCAN") - .cursor_arg(0) - .arg("MATCH") - .arg("team\\*:*") - .arg("COUNT") - .arg(1000), - Ok(redis_test::redis_value!(["0", ["team*:key"]])), - ), - MockCmd::new(redis::cmd("DEL").arg("team*:key"), Ok(1u32)), + assert_eq!( + unscoped.async_flush_cache().await, + Err(Error::UnscopedFlush) + ); +} + +#[rstest] +#[case::plain_namespace("litellm-cache", "litellm-cache:*", "litellm-cache:key")] +#[case::glob_metacharacters_are_escaped("team*", "team\\*:*", "team*:key")] +fn flush_scans_and_deletes_only_namespaced_keys( + #[case] namespace: &str, + #[case] pattern: &str, + #[case] key: &str, +) { + let cache = json_cache(vec![ + scan(pattern, 0, 1000, redis_test::redis_value!(["0", [key]])), + MockCmd::new(redis::cmd("DEL").arg(key), Ok(1u32)), ]) - .assert_all_commands_consumed(); - let scoped = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team*".into())); - scoped.flush_cache().unwrap(); + .with_namespace(Some(namespace.into())); + cache.flush_cache().unwrap(); } +#[rstest] #[tokio::test] -async fn connection_failures_use_the_python_result_contract() { - let error = redis::RedisError::from((redis::ErrorKind::Io, "connection refused")); - let connection = - MockRedisConnection::new([MockCmd::new(redis::cmd("PING"), Err::(error))]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, JsonCodec::::new()); - - let result = cache.test_connection().await.unwrap(); - assert_eq!(result.status, CacheConnectionStatus::Failed); - assert!(result.message.starts_with("Redis connection failed:")); - assert!(result.error.is_some()); +async fn async_flush_deletes_each_scan_page_separately() { + let cache = team(vec![ + scan( + "team:*", + 0, + 1000, + redis_test::redis_value!(["7", ["team:a", "team:b"]]), + ), + MockCmd::new(redis::cmd("DEL").arg("team:a").arg("team:b"), Ok(2u32)), + scan( + "team:*", + 7, + 1000, + redis_test::redis_value!(["0", ["team:c"]]), + ), + MockCmd::new(redis::cmd("DEL").arg("team:c"), Ok(1u32)), + ]); + cache.async_flush_cache().await.unwrap(); } +#[rstest] +fn flushall_ignores_the_namespace() { + team(vec![MockCmd::new(redis::cmd("FLUSHALL"), Ok("OK"))]) + .flushall() + .unwrap(); +} + +#[rstest] #[tokio::test] -async fn batch_reads_keep_order_and_treat_invalid_values_as_invalid_entries() { - let connection = MockRedisConnection::new([MockCmd::new( +async fn batch_reads_keep_order_and_treat_invalid_values_as_invalid_entries( + context: ExactCacheContext, +) { + let cache = tagged(vec![MockCmd::new( redis::cmd("MGET").arg("hit").arg("miss").arg("invalid"), Ok(vec![ redis::Value::BulkString(vec![42, 7]), redis::Value::Nil, redis::Value::BulkString(vec![99, 7]), ]), - )]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, TaggedByteCodec(42)); + )]); assert_eq!( cache - .async_batch_get_cache( - vec!["hit".into(), "miss".into(), "invalid".into()], - ExactCacheContext::default(), - ) + .async_batch_get_cache(vec!["hit".into(), "miss".into(), "invalid".into()], context) .await .unwrap(), vec![BatchEntry::Hit(7), BatchEntry::Miss, BatchEntry::Invalid] ); } +#[rstest] #[tokio::test] -async fn async_flush_deletes_each_scan_page_separately() { - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("SCAN") - .cursor_arg(0) - .arg("MATCH") - .arg("team:*") - .arg("COUNT") - .arg(1000), - Ok(redis_test::redis_value!(["7", ["team:a", "team:b"]])), - ), - MockCmd::new(redis::cmd("DEL").arg("team:a").arg("team:b"), Ok(2u32)), - MockCmd::new( - redis::cmd("SCAN") - .cursor_arg(7) - .arg("MATCH") - .arg("team:*") - .arg("COUNT") - .arg(1000), - Ok(redis_test::redis_value!(["0", ["team:c"]])), - ), - MockCmd::new(redis::cmd("DEL").arg("team:c"), Ok(1u32)), - ]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team".into())); +async fn ttl_pipeline_keeps_each_entry_ttl_and_defaults_missing_ones() { + let mut pipeline = redis::pipe(); + pipeline + .cmd("SETEX") + .arg("ns:team_id:t1") + .arg(60u64) + .arg(r#"{"team_id":"t1"}"#) + .cmd("SETEX") + .arg("ns:u1") + .arg(7u64) + .arg(r#"{"user_id":"u1"}"#) + .cmd("SETEX") + .arg("ns:org_id:o1") + .arg(300u64) + .arg(r#"{"a":1}"#); + let cache = RedisCache::with_connection( + mock(vec![MockCmd::with_values( + pipeline, + Ok(vec!["OK", "OK", "OK"]), + )]), + Some(Duration::from_secs(300)), + JsonCodec::::new(), + ) + .with_namespace(Some("ns".into())); - cache.async_flush_cache().await.unwrap(); + cache + .async_set_cache_pipeline_with_ttls(vec![ + ( + "team_id:t1".into(), + json!({"team_id": "t1"}), + Some(Duration::from_secs(60)), + ), + ( + "u1".into(), + json!({"user_id": "u1"}), + Some(Duration::from_secs(7)), + ), + ("org_id:o1".into(), json!({"a": 1}), None), + ]) + .await + .unwrap(); } +#[rstest] #[tokio::test] -async fn direct_redis_operations_preserve_namespace_values_and_missing_ttls() { - let mut sadd_pipeline = redis::pipe(); - sadd_pipeline - .cmd("SADD") - .arg("team:members") - .arg("a") - .arg("b") - .cmd("EXPIRE") - .arg("team:members") - .arg(600u64) - .ignore(); - let connection = MockRedisConnection::new([ +async fn empty_pipelines_skip_the_round_trip(context: ExactCacheContext) { + let cache = json_cache(Vec::new()); + cache + .async_set_cache_pipeline(Vec::new(), context) + .await + .unwrap(); + cache + .async_set_cache_pipeline_with_ttls(Vec::new()) + .await + .unwrap(); + assert_eq!(cache.delete_cache_keys(Vec::new()).await.unwrap(), 0); + assert_eq!( + cache.async_rpush_pipeline(Vec::new()).await.unwrap(), + Vec::::new() + ); + assert_eq!( + cache.async_lpop_pipeline(Vec::new()).await.unwrap(), + Vec::::new() + ); + assert_eq!( + cache.async_increment_pipeline(Vec::new()).await.unwrap(), + Vec::::new() + ); +} + +#[rstest] +#[tokio::test] +async fn count_reads_parse_integers_and_keep_missing_counters() { + let mget = || { MockCmd::new( redis::cmd("MGET").arg("team:count").arg("team:missing"), Ok(redis_test::redis_value!(["7", nil])), - ), - MockCmd::new( - redis::cmd("MGET").arg("team:count").arg("team:missing"), - Ok(redis_test::redis_value!(["7", nil])), - ), + ) + }; + let cache = team(vec![mget(), mget()]); + let keys = vec!["count".to_string(), "missing".to_string()]; + + assert_eq!(cache.batch_get_counts(&keys).unwrap(), [Some(7), None]); + assert_eq!( + cache.async_batch_get_counts(keys).await.unwrap(), + [Some(7), None] + ); +} + +#[rstest] +#[tokio::test] +async fn pings_run_on_both_paths() { + let cache = team(vec![ MockCmd::new(redis::cmd("PING"), Ok("PONG")), MockCmd::new(redis::cmd("PING"), Ok("PONG")), - MockCmd::new(redis::cmd("TTL").arg("team:missing"), Ok(-2i64)), - MockCmd::new( - redis::cmd("SCAN") - .cursor_arg(0) - .arg("MATCH") - .arg("team:job-*") - .arg("COUNT") - .arg(25), - Ok(redis_test::redis_value!(["4", ["team:job-a"]])), + ]); + assert!(cache.sync_ping().unwrap()); + assert!(cache.ping().await.unwrap()); +} + +#[rstest] +#[case::remaining(12, Some(Duration::from_secs(12)))] +#[case::no_expiry(-1, None)] +#[case::missing(-2, None)] +#[tokio::test] +async fn ttl_reads_hide_negative_replies(#[case] reply: i64, #[case] ttl: Option) { + let cache = team(vec![MockCmd::new( + redis::cmd("TTL").arg("team:key"), + Ok(reply), + )]); + assert_eq!(cache.async_get_ttl("key").await.unwrap(), ttl); +} + +#[rstest] +#[case::explicit_ttl(Some(Duration::from_secs(30)), 30, 1, true)] +#[case::default_ttl(None, 600, 1, true)] +#[case::missing_key(Some(Duration::from_secs(30)), 30, 0, false)] +#[tokio::test] +async fn refresh_ttl_expires_existing_keys_only( + #[case] ttl: Option, + #[case] seconds: u64, + #[case] reply: i64, + #[case] refreshed: bool, +) { + let cache = team(vec![MockCmd::new( + redis::cmd("EXPIRE").arg("team:key").arg(seconds), + Ok(reply), + )]); + assert_eq!( + cache.async_refresh_ttl("key", ttl).await.unwrap(), + refreshed + ); +} + +#[rstest] +#[tokio::test] +async fn scan_stops_at_count_and_bulk_delete_reports_existing_keys() { + let cache = team(vec![ + scan( + "team:job-*", + 0, + 25, + redis_test::redis_value!(["4", ["team:job-a"]]), ), - MockCmd::new( - redis::cmd("SCAN") - .cursor_arg(4) - .arg("MATCH") - .arg("team:job-*") - .arg("COUNT") - .arg(25), - Ok(redis_test::redis_value!(["0", ["team:job-b"]])), + scan( + "team:job-*", + 4, + 25, + redis_test::redis_value!(["0", ["team:job-b"]]), ), MockCmd::new( redis::cmd("DEL").arg("team:job-a").arg("team:job-b"), Ok(2u32), ), - MockCmd::with_values( - sadd_pipeline, - Ok(vec![redis::Value::Int(2), redis::Value::Int(1)]), - ), - MockCmd::new( - redis::cmd("RPUSH").arg("team:queue").arg("a").arg("b"), - Ok(2u32), - ), - MockCmd::new( - redis::cmd("LPOP").arg("team:queue").arg(2usize), - Ok(redis_test::redis_value!(["a", "b"])), - ), - MockCmd::new( - redis::cmd("EVAL") - .arg("return KEYS[1]") - .arg(1usize) - .arg("team:key"), - Ok("team:key"), - ), - MockCmd::new( - redis::cmd("EVAL") - .arg("return KEYS[1]") - .arg(1usize) - .arg("team:key"), - Ok("team:key"), - ), - MockCmd::new(redis::cmd("CLIENT").arg("LIST"), Ok("id=1")), - MockCmd::new(redis::cmd("INFO"), Ok("redis_version:7")), - MockCmd::new(redis::cmd("FLUSHALL"), Ok("OK")), - ]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team".into())); - - assert_eq!( - cache - .batch_get_counts(&["count".into(), "missing".into()]) - .unwrap(), - [Some(7), None] - ); - assert_eq!( - cache - .async_batch_get_counts(vec!["count".into(), "missing".into()]) - .await - .unwrap(), - [Some(7), None] - ); - assert!(cache.sync_ping().unwrap()); - assert!(cache.ping().await.unwrap()); - assert_eq!(cache.async_get_ttl("missing").await.unwrap(), None); + ]); assert_eq!( cache.async_scan_iter("job-", 25).await.unwrap(), ["team:job-a", "team:job-b"] @@ -370,6 +486,25 @@ async fn direct_redis_operations_preserve_namespace_values_and_missing_ttls() { .unwrap(), 2 ); +} + +#[rstest] +#[tokio::test] +async fn sets_add_members_and_arm_the_default_ttl() { + let mut pipeline = redis::pipe(); + pipeline + .cmd("SADD") + .arg("team:members") + .arg("a") + .arg("b") + .cmd("EXPIRE") + .arg("team:members") + .arg(600u64) + .ignore(); + let cache = team(vec![MockCmd::with_values( + pipeline, + Ok(vec![redis::Value::Int(2), redis::Value::Int(1)]), + )]); assert_eq!( cache .async_set_cache_sadd("members", vec!["a".into(), "b".into()], None) @@ -377,6 +512,32 @@ async fn direct_redis_operations_preserve_namespace_values_and_missing_ttls() { .unwrap(), 2 ); + assert_eq!( + cache + .async_set_cache_sadd("members", Vec::new(), None) + .await, + Err(Error::InvalidEntry) + ); +} + +#[rstest] +#[tokio::test] +async fn queues_push_and_pop_namespaced_lists() { + let cache = team(vec![ + MockCmd::new( + redis::cmd("RPUSH").arg("team:queue").arg("a").arg("b"), + Ok(2u32), + ), + MockCmd::new( + redis::cmd("INFO"), + Ok("# Server\r\nredis_version:7.2.4\r\n"), + ), + MockCmd::new( + redis::cmd("LPOP").arg("team:queue").arg(2usize), + Ok(redis_test::redis_value!(["a", "b"])), + ), + MockCmd::new(redis::cmd("LPOP").arg("team:queue"), Ok("c")), + ]); assert_eq!( cache .async_rpush("queue", vec!["a".into(), "b".into()]) @@ -384,10 +545,159 @@ async fn direct_redis_operations_preserve_namespace_values_and_missing_ttls() { .unwrap(), 2 ); + assert_eq!( + cache.async_rpush("queue", Vec::new()).await, + Err(Error::InvalidEntry) + ); assert_eq!( cache.async_lpop("queue", Some(2)).await.unwrap(), RedisLpopResult::Values(vec![b"a".to_vec(), b"b".to_vec()]) ); + assert_eq!( + cache.async_lpop("queue", None).await.unwrap(), + RedisLpopResult::Value(b"c".to_vec()) + ); +} + +/// Python `RedisCache.async_lpop` checks `redis_version` from `INFO` and, below major version 7, +/// pops a counted batch as `count` single-command `LPOP` pipelines, dropping `None` replies. +#[rstest] +#[tokio::test] +async fn counted_lpop_falls_back_to_single_pops_below_redis_7() { + let single_pop = || { + let mut pipeline = redis::pipe(); + pipeline.cmd("LPOP").arg("team:queue"); + pipeline + }; + let cache = team(vec![ + MockCmd::new( + redis::cmd("INFO"), + Ok("# Server\r\nredis_version:6.2.14\r\n"), + ), + MockCmd::with_values(single_pop(), Ok(vec![redis_test::redis_value!("a")])), + MockCmd::with_values(single_pop(), Ok(vec![redis_test::redis_value!("b")])), + MockCmd::with_values(single_pop(), Ok(vec![redis::Value::Nil])), + ]); + + assert_eq!( + cache.async_lpop("queue", Some(3)).await.unwrap(), + RedisLpopResult::Values(vec![b"a".to_vec(), b"b".to_vec()]) + ); +} + +/// Python keeps `redis_version = "Unknown"` when `INFO` fails and then assumes +/// `DEFAULT_REDIS_MAJOR_VERSION` (7), so a counted pop is one `LPOP key count`. The version is read +/// once: the second pop sends no second `INFO`. +#[rstest] +#[tokio::test] +async fn counted_lpop_assumes_redis_7_when_info_fails() { + let cache = team(vec![ + MockCmd::new( + redis::cmd("INFO"), + Err::(redis::RedisError::from((redis::ErrorKind::Io, "down"))), + ), + MockCmd::new( + redis::cmd("LPOP").arg("team:queue").arg(2usize), + Ok(redis_test::redis_value!(["a", "b"])), + ), + MockCmd::new( + redis::cmd("LPOP").arg("team:queue").arg(1usize), + Ok(redis_test::redis_value!(["c"])), + ), + ]); + + assert_eq!( + cache.async_lpop("queue", Some(2)).await.unwrap(), + RedisLpopResult::Values(vec![b"a".to_vec(), b"b".to_vec()]) + ); + assert_eq!( + cache.async_lpop("queue", Some(1)).await.unwrap(), + RedisLpopResult::Values(vec![b"c".to_vec()]) + ); +} + +fn push_and_trim(start: i64) -> redis::Pipeline { + let mut pipeline = redis::pipe(); + pipeline + .atomic() + .cmd("RPUSH") + .arg("ns:buf") + .arg("c") + .arg("d") + .cmd("LTRIM") + .arg("ns:buf") + .arg(start) + .arg(-1); + pipeline +} + +#[rstest] +#[case::keeps_newest_entries(3, -3)] +#[case::zero_keeps_everything(0, 0)] +#[tokio::test] +async fn rpush_and_trim_runs_push_and_trim_in_one_transaction( + #[case] max_len: usize, + #[case] start: i64, +) { + let cache = json_cache(vec![MockCmd::with_values( + push_and_trim(start), + Ok(vec![redis::Value::Array(vec![ + redis::Value::Int(4), + redis::Value::Okay, + ])]), + )]) + .with_namespace(Some("ns".into())); + + assert_eq!( + cache + .async_rpush_and_trim("buf", vec!["c".into(), "d".into()], max_len) + .await + .unwrap(), + 4 + ); +} + +#[rstest] +#[tokio::test] +async fn rpush_and_trim_raises_when_a_queued_command_fails() { + let wrong_type = redis::parse_redis_value( + b"-WRONGTYPE Operation against a key holding the wrong kind of value\r\n", + ) + .unwrap(); + let cache = json_cache(vec![MockCmd::with_values( + push_and_trim(-3), + Ok(vec![redis::Value::Array(vec![ + wrong_type, + redis::Value::Okay, + ])]), + )]) + .with_namespace(Some("ns".into())); + + assert_eq!( + cache + .async_rpush_and_trim("buf", vec!["c".into(), "d".into()], 3) + .await, + Err(Error::Unavailable) + ); + assert_eq!( + cache.async_rpush_and_trim("buf", Vec::new(), 3).await, + Err(Error::InvalidEntry) + ); +} + +#[rstest] +#[tokio::test] +async fn scripts_and_eval_namespace_their_keys() { + let eval = || { + MockCmd::new( + redis::cmd("EVAL") + .arg("return KEYS[1]") + .arg(1usize) + .arg("team:key"), + Ok("team:key"), + ) + }; + let cache = team(vec![eval(), eval()]); assert_eq!( cache .async_eval("return KEYS[1]".into(), vec!["key".into()], Vec::new()) @@ -403,13 +713,21 @@ async fn direct_redis_operations_preserve_namespace_values_and_missing_ttls() { .unwrap(), redis::Value::BulkString(b"team:key".to_vec()) ); - assert_eq!(cache.client_list().unwrap(), "id=1"); - assert_eq!(cache.info().unwrap(), "redis_version:7"); - cache.flushall().unwrap(); } +#[rstest] +fn client_list_and_info_return_server_text() { + let cache = team(vec![ + MockCmd::new(redis::cmd("CLIENT").arg("LIST"), Ok("id=1")), + MockCmd::new(redis::cmd("INFO"), Ok("redis_version:7")), + ]); + assert_eq!(cache.client_list().unwrap(), "id=1"); + assert_eq!(cache.info().unwrap(), "redis_version:7"); +} + +#[rstest] #[tokio::test] -async fn direct_redis_pipelines_preserve_operation_order() { +async fn pipelines_preserve_operation_order() { let mut rpush_pipeline = redis::pipe(); rpush_pipeline .cmd("RPUSH") @@ -425,19 +743,20 @@ async fn direct_redis_pipelines_preserve_operation_order() { .arg(2usize) .cmd("LPOP") .arg("team:b"); - let connection = MockRedisConnection::new([ + let queue = team(vec![ MockCmd::with_values( rpush_pipeline, Ok(vec![redis::Value::Int(1), redis::Value::Int(2)]), ), + MockCmd::new( + redis::cmd("INFO"), + Ok("# Server\r\nredis_version:7.2.4\r\n"), + ), MockCmd::with_values( lpop_pipeline, Ok(vec![redis_test::redis_value!(["one"]), redis::Value::Nil]), ), - ]) - .assert_all_commands_consumed(); - let queue = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team".into())); + ]); assert_eq!( queue @@ -474,7 +793,59 @@ async fn direct_redis_pipelines_preserve_operation_order() { RedisLpopResult::Missing, ] ); +} +/// Below major version 7 a counted `LPOP` is unsupported, so a pipeline that mixes counted and +/// plain pops runs each operation through `async_lpop`: `count` single-`LPOP` pipelines for the +/// counted ones, a bare `LPOP` for the rest. No `LPOP key count` reaches the connection. +#[rstest] +#[tokio::test] +async fn lpop_pipeline_pops_one_at_a_time_below_redis_7() { + let single_pop = |key: &str| { + let mut pipeline = redis::pipe(); + pipeline.cmd("LPOP").arg(key); + pipeline + }; + let cache = team(vec![ + MockCmd::new( + redis::cmd("INFO"), + Ok("# Server\r\nredis_version:6.2.14\r\n"), + ), + MockCmd::with_values( + single_pop("team:a"), + Ok(vec![redis_test::redis_value!("one")]), + ), + MockCmd::with_values( + single_pop("team:a"), + Ok(vec![redis_test::redis_value!("two")]), + ), + MockCmd::new(redis::cmd("LPOP").arg("team:b"), Ok(redis::Value::Nil)), + ]); + + assert_eq!( + cache + .async_lpop_pipeline(vec![ + RedisLpopOperation { + key: "a".into(), + count: Some(2), + }, + RedisLpopOperation { + key: "b".into(), + count: None, + }, + ]) + .await + .unwrap(), + [ + RedisLpopResult::Values(vec![b"one".to_vec(), b"two".to_vec()]), + RedisLpopResult::Missing, + ] + ); +} + +#[rstest] +#[tokio::test] +async fn increment_pipeline_expires_only_operations_with_a_ttl() { let mut increment_pipeline = redis::pipe(); increment_pipeline .cmd("INCRBYFLOAT") @@ -487,17 +858,14 @@ async fn direct_redis_pipelines_preserve_operation_order() { .cmd("INCRBYFLOAT") .arg("team:counter") .arg(2.0f64); - let connection = MockRedisConnection::new([MockCmd::with_values( + let counters = team(vec![MockCmd::with_values( increment_pipeline, Ok(vec![ redis::Value::BulkString(b"1.5".to_vec()), redis::Value::Int(1), redis::Value::BulkString(b"3.5".to_vec()), ]), - )]) - .assert_all_commands_consumed(); - let counters = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team".into())); + )]); assert_eq!( counters .async_increment_pipeline(vec![ @@ -518,6 +886,11 @@ async fn direct_redis_pipelines_preserve_operation_order() { ); } +const INCREMENT_SCRIPT: &str = concat!( + "local value = redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]); ", + "if redis.call('TTL', KEYS[1]) == -1 then ", + "redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return value" +); const INCREMENT_WITH_FLOOR_SCRIPT: &str = concat!( "local count = redis.call('INCRBY', KEYS[1], ARGV[1]); ", "if count < 0 then count = redis.call('INCRBY', KEYS[1], -count); end; ", @@ -532,20 +905,93 @@ const SET_MAX_SCRIPT: &str = concat!( "return ARGV[1]; end; return current" ); +fn increment_script(amount: f64) -> MockCmd { + MockCmd::new( + redis::cmd("EVAL") + .arg(INCREMENT_SCRIPT) + .arg(1) + .arg("counter") + .arg(amount) + .arg(600), + Ok("4.5"), + ) +} + +#[rstest] +#[tokio::test] +async fn increments_keep_an_existing_ttl_in_one_atomic_script(context: ExactCacheContext) { + let cache = RedisCache::with_connection( + mock(vec![increment_script(2.5), increment_script(2.5)]), + None, + JsonCodec::::new(), + ); + + assert_eq!( + cache + .increment_cache("counter", 2.5, context.clone()) + .unwrap(), + 4.5 + ); + assert_eq!( + cache + .async_increment("counter", 2.5, context, false) + .await + .unwrap(), + 4.5 + ); +} + +#[rstest] +#[case::explicit_ttl(Some(Duration::from_secs(60)), 60u64)] +#[case::default_ttl(None, 600u64)] +#[tokio::test] +async fn refresh_ttl_increments_rearm_the_ttl_in_the_same_round_trip( + #[case] ttl: Option, + #[case] seconds: u64, +) { + let mut pipeline = redis::pipe(); + pipeline + .cmd("INCRBYFLOAT") + .arg("ns:spend:key:k") + .arg(1.5f64) + .cmd("EXPIRE") + .arg("ns:spend:key:k") + .arg(seconds); + let cache = json_cache(vec![MockCmd::with_values( + pipeline, + Ok(vec![ + redis::Value::BulkString(b"1.5".to_vec()), + redis::Value::Int(1), + ]), + )]) + .with_namespace(Some("ns".into())); + + assert_eq!( + cache + .async_increment("spend:key:k", 1.5, ExactCacheContext { ttl }, true) + .await + .unwrap(), + 1.5 + ); +} + +#[rstest] #[tokio::test] async fn counter_repairs_are_atomic_and_use_default_ttl() { let floor = || { - redis::cmd("EVAL") - .arg(INCREMENT_WITH_FLOOR_SCRIPT) - .arg(1) - .arg("team:counter") - .arg(-2i64) - .arg(30u64) - .clone() + MockCmd::new( + redis::cmd("EVAL") + .arg(INCREMENT_WITH_FLOOR_SCRIPT) + .arg(1) + .arg("team:counter") + .arg(-2i64) + .arg(30u64), + Ok(0i64), + ) }; - let connection = MockRedisConnection::new([ - MockCmd::new(floor(), Ok(0i64)), - MockCmd::new(floor(), Ok(0i64)), + let cache = team(vec![ + floor(), + floor(), MockCmd::new( redis::cmd("EVAL") .arg(SET_MAX_SCRIPT) @@ -555,10 +1001,7 @@ async fn counter_repairs_are_atomic_and_use_default_ttl() { .arg(600u64), Ok("4.5"), ), - ]) - .assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, JsonCodec::::new()) - .with_namespace(Some("team".into())); + ]); assert_eq!( cache @@ -587,38 +1030,37 @@ const CLAIM_SCRIPT: &str = concat!( "elseif ARGV[4] == '1' then redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return 1" ); -fn claim_eval(expected: &str, write: &str, refresh: bool) -> redis::Cmd { - let mut cmd = redis::cmd("EVAL"); - cmd.arg(CLAIM_SCRIPT) - .arg(1) - .arg("pin") - .arg(expected) - .arg(600) - .arg(write) - .arg(u8::from(refresh)); - cmd +fn claim_eval(expected: &str, write: &str, refresh: bool, applied: i64) -> MockCmd { + MockCmd::new( + redis::cmd("EVAL") + .arg(CLAIM_SCRIPT) + .arg(1) + .arg("pin") + .arg(expected) + .arg(600) + .arg(write) + .arg(u8::from(refresh)), + Ok(applied), + ) } +#[rstest] #[tokio::test] -async fn claims_match_eligible_values_written_by_another_encoder() { +async fn claims_match_eligible_values_written_by_another_encoder(context: ExactCacheContext) { let python_payload = r#"{"model_id": "a", "deployment": "east"}"#; - let stored = serde_json::json!({"deployment": "east", "model_id": "a"}); - let candidate = serde_json::json!({"model_id": "b"}); - let connection = MockRedisConnection::new([ + let stored = json!({"deployment": "east", "model_id": "a"}); + let cache = json_cache(vec![ MockCmd::new(redis::cmd("GET").arg("pin"), Ok(python_payload)), - MockCmd::new(claim_eval(python_payload, "", true), Ok(1)), - ]) - .assert_all_commands_consumed(); - let cache = - RedisCache::with_connection(connection, None, JsonCodec::::new()); + claim_eval(python_payload, "", true, 1), + ]); assert_eq!( cache .async_claim_cache( "pin", - candidate, + json!({"model_id": "b"}), vec![stored.clone()], - ExactCacheContext::default() + context ) .await .unwrap(), @@ -626,78 +1068,91 @@ async fn claims_match_eligible_values_written_by_another_encoder() { ); } -#[test] -fn claims_retry_when_the_key_changes_and_replace_ineligible_winners() { - let candidate = serde_json::json!({"model_id": "b"}); +#[rstest] +fn claims_retry_when_the_key_changes_and_replace_ineligible_winners(context: ExactCacheContext) { + let candidate = json!({"model_id": "b"}); let payload = r#"{"model_id":"b"}"#; - let connection = MockRedisConnection::new([ + let cache = json_cache(vec![ MockCmd::new(redis::cmd("GET").arg("pin"), Ok(redis::Value::Nil)), - MockCmd::new(claim_eval("", payload, false), Ok(0)), + claim_eval("", payload, false, 0), MockCmd::new(redis::cmd("GET").arg("pin"), Ok(r#"{"model_id":"gone"}"#)), - MockCmd::new(claim_eval(r#"{"model_id":"gone"}"#, payload, false), Ok(1)), - ]) - .assert_all_commands_consumed(); - let cache = - RedisCache::with_connection(connection, None, JsonCodec::::new()); + claim_eval(r#"{"model_id":"gone"}"#, payload, false, 1), + ]); assert_eq!( cache .claim_cache( "pin", candidate.clone(), - &[serde_json::json!({"model_id": "a"})], - ExactCacheContext::default() + &[json!({"model_id": "a"})], + context ) .unwrap(), candidate ); } -#[test] -fn claims_without_eligible_values_keep_the_winner_without_refreshing_its_ttl() { +#[rstest] +fn claims_without_eligible_values_keep_the_winner_without_refreshing_its_ttl( + context: ExactCacheContext, +) { let stored = r#"{"model_id": "a"}"#; - let connection = MockRedisConnection::new([ + let cache = json_cache(vec![ MockCmd::new(redis::cmd("GET").arg("pin"), Ok(stored)), - MockCmd::new(claim_eval(stored, "", false), Ok(1)), - ]) - .assert_all_commands_consumed(); - let cache = - RedisCache::with_connection(connection, None, JsonCodec::::new()); + claim_eval(stored, "", false, 1), + ]); assert_eq!( cache - .claim_cache( - "pin", - serde_json::json!({"model_id": "b"}), - &[], - ExactCacheContext::default() - ) + .claim_cache("pin", json!({"model_id": "b"}), &[], context) .unwrap(), - serde_json::json!({"model_id": "a"}) + json!({"model_id": "a"}) ); } +#[rstest] #[tokio::test] -async fn async_increment_runs_the_atomic_script() { - let mut eval = redis::cmd("EVAL"); - eval.arg(concat!( - "local value = redis.call('INCRBYFLOAT', KEYS[1], ARGV[1]); ", - "if redis.call('TTL', KEYS[1]) == -1 then ", - "redis.call('EXPIRE', KEYS[1], ARGV[2]); end; return value" - )) - .arg(1) - .arg("counter") - .arg(2.5f64) - .arg(600); - let connection = - MockRedisConnection::new([MockCmd::new(eval, Ok("4.5"))]).assert_all_commands_consumed(); - let cache = RedisCache::with_connection(connection, None, JsonCodec::::new()); +async fn test_connection_reports_success_with_the_python_message() { + let cache = team(vec![MockCmd::new(redis::cmd("PING"), Ok("PONG"))]); - assert_eq!( - cache - .async_increment("counter", 2.5, ExactCacheContext::default()) - .await - .unwrap(), - 4.5 - ); + let result = cache.test_connection().await.unwrap(); + assert_eq!(result.status, CacheConnectionStatus::Success); + assert_eq!(result.message, "Redis connection test successful"); + assert_eq!(result.error, None); +} + +#[rstest] +#[case::unexpected_reply(Ok("NOPE"), "Redis ping returned False", false)] +#[case::connection_refused( + Err(redis::RedisError::from((redis::ErrorKind::Io, "connection refused"))), + "Redis connection failed:", + true +)] +#[tokio::test] +async fn test_connection_failures_use_the_python_result_contract( + #[case] reply: redis::RedisResult<&'static str>, + #[case] message: &str, + #[case] has_error: bool, +) { + let cache = json_cache(vec![MockCmd::new(redis::cmd("PING"), reply)]); + + let result = cache.test_connection().await.unwrap(); + assert_eq!(result.status, CacheConnectionStatus::Failed); + assert!(result.message.starts_with(message), "{}", result.message); + assert_eq!(result.error.is_some(), has_error); +} + +#[rstest] +#[tokio::test] +async fn disconnect_keeps_a_caller_owned_connection_usable() { + let cache = team(vec![MockCmd::new(redis::cmd("PING"), Ok("PONG"))]); + cache.disconnect().await.unwrap(); + assert!(cache.ping().await.unwrap()); +} + +#[rstest] +#[tokio::test] +async fn disconnect_drains_an_idle_pool_without_connecting() { + let cache = RedisCache::new("redis://127.0.0.1:1", None, JsonCodec::::new()).unwrap(); + cache.disconnect().await.unwrap(); } diff --git a/litellm-rust/crates/cache-redis/tests/cluster.rs b/litellm-rust/crates/cache-redis/tests/cluster.rs index 2c3fc818b66..c1a9a70a5d2 100644 --- a/litellm-rust/crates/cache-redis/tests/cluster.rs +++ b/litellm-rust/crates/cache-redis/tests/cluster.rs @@ -1,100 +1,67 @@ -//! Contract tests against a real Redis Cluster. Set `LITELLM_TEST_REDIS_CLUSTER_NODES` to a -//! comma separated `host:port` list (for example `127.0.0.1:7000,127.0.0.1:7001`) to run them. +//! Tests against a real Redis Cluster. Set `LITELLM_TEST_REDIS_CLUSTER_NODES` to a comma +//! separated `host:port` list (for example `127.0.0.1:7000,127.0.0.1:7001`) to run them. -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +mod support; + +use std::{collections::HashSet, time::Duration}; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheConnectionStatus, CacheScript, ClaimCache, - CounterCache, DeleteCache, Error, ExactCacheContext, FlushCache, IncrementOperation, JsonCodec, - ScriptCache, + BaseCache, BatchCache, BatchEntry, BoundedCounterCache, BulkDeleteCache, CacheConnectionStatus, + CacheScript, ClaimCache, ClientInfoCache, ConnectionCache, CounterCache, DeleteCache, + DisconnectCache, Error, ExactCacheContext, FlushCache, IncrementOperation, JsonCodec, + PingCache, QueueCache, RefreshTtlCache, ScanCache, ScriptCache, SetCache, TtlCache, + TtlPipelineCache, }; use litellm_cache_redis::{ RedisArg, RedisCache, RedisLpopOperation, RedisLpopResult, RedisNode, RedisRpushOperation, RedisTopology, }; use redis::cluster_routing::Slot; +use rstest::{fixture, rstest}; +use serde_json::json; +use support::{JsonCache, cluster_cache, cluster_url}; -type Cache = RedisCache>; +type Counter = RedisCache>; -fn topology() -> Option { - let nodes = std::env::var("LITELLM_TEST_REDIS_CLUSTER_NODES").ok()?; - let startup_nodes = nodes - .split(',') - .map(|node| { - let (host, port) = node.trim().rsplit_once(':').expect("host:port"); - RedisNode { - host: host.to_string(), - port: port.parse().expect("port"), - } - }) - .collect(); - Some(RedisTopology::Cluster { startup_nodes }) +#[fixture] +fn cache(#[default("cache")] label: &str) -> Option { + cluster_cache(label, Duration::from_secs(120), JsonCodec::new()) } -fn namespace(label: &str) -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("cluster-test:{label}:{nanos}") +#[fixture] +fn counter(#[default("counter")] label: &str) -> Option { + cluster_cache(label, Duration::from_secs(60), JsonCodec::new()) } -fn cluster_url() -> String { - std::env::var("LITELLM_TEST_REDIS_CLUSTER_URL") - .unwrap_or_else(|_| "redis://127.0.0.1:7000".into()) -} - -fn cluster_cache(label: &str) -> Option { - let topology = topology()?; - Some( - Cache::connect( - &cluster_url(), - &topology, - Some(Duration::from_secs(120)), - JsonCodec::new(), - ) - .expect("cluster connection") - .with_namespace(Some(namespace(label))), - ) -} - -fn counter_cache(label: &str) -> Option>> { - let topology = topology()?; - Some( - RedisCache::connect( - &cluster_url(), - &topology, - Some(Duration::from_secs(60)), - JsonCodec::new(), - ) - .expect("cluster connection") - .with_namespace(Some(namespace(label))), - ) +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext::default() } fn multi_slot_keys(count: usize) -> Vec { let keys: Vec = (0..count).map(|index| format!("key-{index}")).collect(); - let slots: std::collections::HashSet = keys.iter().map(Slot::for_key).collect(); + let slots: HashSet = keys.iter().map(Slot::for_key).collect(); assert!(slots.len() > 1, "keys must span multiple slots"); keys } -macro_rules! cluster_or_skip { - ($label:expr) => { - match cluster_cache($label) { - Some(cache) => cache, - None => return, - } - }; +fn seconds(seconds: u64) -> Option { + Some(Duration::from_secs(seconds)) } -#[test] -fn constructor_rejects_clusters_without_startup_nodes() { - let error = Cache::connect( - "redis://127.0.0.1:7000", - &RedisTopology::Cluster { - startup_nodes: Vec::new(), - }, +#[rstest] +#[case::no_startup_nodes("redis://127.0.0.1:7000", Vec::new())] +#[case::unix_socket_url( + "redis+unix:///tmp/redis.sock", + vec![RedisNode { host: "127.0.0.1".into(), port: 7000 }] +)] +fn constructor_rejects_unusable_cluster_configs( + #[case] url: &str, + #[case] startup_nodes: Vec, +) { + let error = JsonCache::connect( + url, + &RedisTopology::Cluster { startup_nodes }, None, JsonCodec::new(), ) @@ -102,60 +69,56 @@ fn constructor_rejects_clusters_without_startup_nodes() { assert!(matches!(error, Some(Error::Unavailable))); } -#[test] -fn constructor_rejects_unix_socket_urls_for_clusters() { - let error = Cache::connect( - "redis+unix:///tmp/redis.sock", - &RedisTopology::Cluster { - startup_nodes: vec![RedisNode { - host: "127.0.0.1".into(), - port: 7000, - }], - }, - None, - JsonCodec::new(), - ) - .err(); - assert!(matches!(error, Some(Error::Unavailable))); -} - -#[test] -fn single_key_operations_round_trip_with_ttl_rounding() { - let cache = cluster_or_skip!("single"); +#[rstest] +#[tokio::test] +async fn single_key_operations_round_trip_with_ttl_rounding( + #[with("single")] cache: Option, +) { + let Some(cache) = cache else { return }; let context = ExactCacheContext { ttl: Some(Duration::from_millis(1500)), }; let keys = multi_slot_keys(12); for (index, key) in keys.iter().enumerate() { cache - .set_cache(key, serde_json::json!({ "index": index }), &context) + .set_cache(key, json!({ "index": index }), &context) .unwrap(); } for (index, key) in keys.iter().enumerate() { assert_eq!( cache.get_cache(key, &context).unwrap(), - Some(serde_json::json!({ "index": index })) + Some(json!({ "index": index })) ); } - let runtime = tokio::runtime::Runtime::new().unwrap(); - let ttl = runtime.block_on(cache.async_get_ttl(&keys[0])).unwrap(); - assert_eq!(ttl, Some(2)); + assert_eq!(cache.async_get_ttl(&keys[0]).await.unwrap(), seconds(2)); + assert!( + cache + .async_refresh_ttl(&keys[0], seconds(40)) + .await + .unwrap() + ); + assert_eq!(cache.async_get_ttl(&keys[0]).await.unwrap(), seconds(40)); cache.delete_cache(&keys[0]).unwrap(); assert_eq!(cache.get_cache(&keys[0], &context).unwrap(), None); + assert!(!cache.async_refresh_ttl(&keys[0], None).await.unwrap()); assert!(cache.sync_ping().unwrap()); + cache.async_flush_cache().await.unwrap(); } +#[rstest] #[tokio::test] -async fn batch_reads_span_slots_and_preserve_order_with_malformed_entries() { - let cache = cluster_or_skip!("batch"); - let context = ExactCacheContext::default(); +async fn batch_reads_span_slots_and_preserve_order_with_malformed_entries( + #[with("batch")] cache: Option, + context: ExactCacheContext, +) { + let Some(cache) = cache else { return }; let keys = multi_slot_keys(40); for (index, key) in keys.iter().enumerate() { if index % 5 == 0 { continue; } cache - .async_set_cache(key, serde_json::json!(index), context.clone()) + .async_set_cache(key, json!(index), context.clone()) .await .unwrap(); } @@ -181,41 +144,65 @@ async fn batch_reads_span_slots_and_preserve_order_with_malformed_entries() { } else if index % 5 == 0 { BatchEntry::Miss } else { - BatchEntry::Hit(serde_json::json!(index)) + BatchEntry::Hit(json!(index)) }; assert_eq!(*entry, expected, "entry {index}"); } - let sync_entries = cache.batch_get_cache(&keys, &context).unwrap(); - assert_eq!(sync_entries, entries); + assert_eq!(cache.batch_get_cache(&keys, &context).unwrap(), entries); cache.delete_cache_keys(keys.clone()).await.unwrap(); let entries = cache.async_batch_get_cache(keys, context).await.unwrap(); assert!(entries.iter().all(|entry| *entry == BatchEntry::Miss)); } +#[rstest] #[tokio::test] -async fn pipelines_group_by_slot_and_return_results_in_submission_order() { - let cache = cluster_or_skip!("pipeline"); +async fn pipelines_group_by_slot_and_return_results_in_submission_order( + #[with("pipeline")] cache: Option, + counter: Option, + context: ExactCacheContext, +) { + let (Some(cache), Some(counter)) = (cache, counter) else { + return; + }; let keys = multi_slot_keys(30); - let entries = keys - .iter() - .enumerate() - .map(|(index, key)| (key.clone(), serde_json::json!(index))) - .collect(); cache - .async_set_cache_pipeline(entries, ExactCacheContext::default()) + .async_set_cache_pipeline( + keys.iter() + .enumerate() + .map(|(index, key)| (key.clone(), json!(index))) + .collect(), + context.clone(), + ) .await .unwrap(); let hits = cache - .async_batch_get_cache(keys.clone(), ExactCacheContext::default()) + .async_batch_get_cache(keys.clone(), context.clone()) .await .unwrap(); assert!( hits.iter() .enumerate() - .all(|(index, entry)| *entry == BatchEntry::Hit(serde_json::json!(index))) + .all(|(index, entry)| *entry == BatchEntry::Hit(json!(index))) ); + cache + .async_set_cache_pipeline_with_ttls( + keys.iter() + .enumerate() + .map(|(index, key)| (key.clone(), json!(index), seconds(index as u64 + 10))) + .collect(), + ) + .await + .unwrap(); + for (index, key) in keys.iter().enumerate() { + assert_eq!( + cache.async_get_ttl(key).await.unwrap(), + seconds(index as u64 + 10), + "{key}" + ); + } + let queues: Vec = keys.iter().map(|key| format!("queue:{key}")).collect(); let pushed = cache .async_rpush_pipeline( @@ -265,9 +252,6 @@ async fn pipelines_group_by_slot_and_return_results_in_submission_order() { } let counters: Vec = keys.iter().map(|key| format!("counter:{key}")).collect(); - let Some(counter) = counter_cache("counter") else { - return; - }; let totals = counter .async_increment_pipeline( counters @@ -284,25 +268,63 @@ async fn pipelines_group_by_slot_and_return_results_in_submission_order() { .unwrap(); let expected: Vec = (0..keys.len()).map(|index| index as f64 + 0.5).collect(); assert_eq!(totals, expected); - assert_eq!(counter.async_get_ttl(&counters[0]).await.unwrap(), Some(30)); + assert_eq!( + counter.async_get_ttl(&counters[0]).await.unwrap(), + seconds(30) + ); assert_eq!(counter.async_get_ttl(&counters[1]).await.unwrap(), None); counter.async_flush_cache().await.unwrap(); cache.async_flush_cache().await.unwrap(); } +#[rstest] #[tokio::test] -async fn scan_and_scoped_flush_cover_every_primary() { - let cache = cluster_or_skip!("flush"); - let other = cluster_or_skip!("other"); - let context = ExactCacheContext::default(); +async fn rpush_and_trim_is_one_transaction_on_the_key_slot( + #[with("trim")] cache: Option, +) { + let Some(cache) = cache else { return }; + let values = |values: &[&str]| values.iter().map(|value| RedisArg::from(*value)).collect(); + assert_eq!( + cache + .async_rpush_and_trim("buf", values(&["a", "b"]), 3) + .await + .unwrap(), + 2 + ); + assert_eq!( + cache + .async_rpush_and_trim("buf", values(&["c", "d"]), 3) + .await + .unwrap(), + 4 + ); + assert_eq!( + cache.async_lpop("buf", Some(10)).await.unwrap(), + RedisLpopResult::Values(vec![b"b".to_vec(), b"c".to_vec(), b"d".to_vec()]) + ); + cache.async_flush_cache().await.unwrap(); +} + +#[rstest] +#[tokio::test] +async fn scan_and_scoped_flush_cover_every_primary( + #[with("flush")] cache: Option, + #[from(cache)] + #[with("other")] + other: Option, + context: ExactCacheContext, +) { + let (Some(cache), Some(other)) = (cache, other) else { + return; + }; let keys = multi_slot_keys(60); for key in &keys { cache - .async_set_cache(key, serde_json::json!(true), context.clone()) + .async_set_cache(key, json!(true), context.clone()) .await .unwrap(); other - .async_set_cache(key, serde_json::json!(true), context.clone()) + .async_set_cache(key, json!(true), context.clone()) .await .unwrap(); } @@ -325,7 +347,7 @@ async fn scan_and_scoped_flush_cover_every_primary() { let kept = other.async_batch_get_cache(keys, context).await.unwrap(); assert!( kept.iter() - .all(|entry| *entry == BatchEntry::Hit(serde_json::json!(true))) + .all(|entry| *entry == BatchEntry::Hit(json!(true))) ); other.async_flush_cache().await.unwrap(); } @@ -361,9 +383,10 @@ fn ping_calls_per_node(startup: &redis::Client) -> Vec<(String, u64)> { counts } +#[rstest] #[tokio::test] -async fn ping_reaches_every_node() { - let cache = cluster_or_skip!("ping"); +async fn ping_reaches_every_node(#[with("ping")] cache: Option) { + let Some(cache) = cache else { return }; let startup = redis::Client::open(cluster_url()).unwrap(); let before = ping_calls_per_node(&startup); assert!(before.len() >= 2, "{before:?}"); @@ -375,14 +398,63 @@ async fn ping_reaches_every_node() { assert!(cache.sync_ping().unwrap()); let result = cache.test_connection().await.unwrap(); assert_eq!(result.status, CacheConnectionStatus::Success); + assert_eq!(result.message, "Redis Cluster connection test successful"); } +#[rstest] #[tokio::test] -async fn counters_claims_scripts_and_sets_work_on_the_cluster() { - let Some(counter) = counter_cache("counter") else { +async fn disconnect_closes_idle_connections_and_reconnects_on_demand( + #[with("disconnect")] cache: Option, +) { + let Some(cache) = cache else { return }; + assert!(cache.ping().await.unwrap()); + cache.disconnect().await.unwrap(); + assert!(cache.ping().await.unwrap()); +} + +#[rstest] +#[case::keep_existing_ttl(false)] +#[case::refresh_ttl(true)] +#[tokio::test] +async fn increments_refresh_the_ttl_only_when_asked( + counter: Option, + #[case] refresh_ttl: bool, +) { + let Some(counter) = counter else { return }; + let context = ExactCacheContext { ttl: seconds(60) }; + counter + .async_set_cache("spend", 0.0, ExactCacheContext { ttl: seconds(600) }) + .await + .unwrap(); + assert_eq!( + counter + .async_increment("spend", 1.5, context.clone(), refresh_ttl) + .await + .unwrap(), + 1.5 + ); + assert_eq!( + counter + .async_increment("spend", 2.0, context, refresh_ttl) + .await + .unwrap(), + 3.5 + ); + let ttl = counter.async_get_ttl("spend").await.unwrap().unwrap(); + assert_eq!(ttl <= Duration::from_secs(60), refresh_ttl, "{ttl:?}"); + counter.async_flush_cache().await.unwrap(); +} + +#[rstest] +#[tokio::test] +async fn counters_claims_scripts_and_sets_work_on_the_cluster( + counter: Option, + #[with("claim")] cache: Option, + context: ExactCacheContext, +) { + let (Some(counter), Some(cache)) = (counter, cache) else { return; }; - let context = ExactCacheContext::default(); assert_eq!( counter .increment_cache("spend", 1.5, context.clone()) @@ -391,7 +463,7 @@ async fn counters_claims_scripts_and_sets_work_on_the_cluster() { ); assert_eq!( counter - .async_increment("spend", 2.0, context.clone()) + .async_increment("spend", 2.0, context.clone(), false) .await .unwrap(), 3.5 @@ -413,9 +485,8 @@ async fn counters_claims_scripts_and_sets_work_on_the_cluster() { assert_eq!(counter.async_set_max("peak", 2.0, None).await.unwrap(), 4.0); counter.flush_cache().unwrap(); - let cache = cluster_or_skip!("claim"); - let owner = serde_json::json!("owner-a"); - let rival = serde_json::json!("owner-b"); + let owner = json!("owner-a"); + let rival = json!("owner-b"); assert_eq!( cache .claim_cache("lock", owner.clone(), &[], context.clone()) @@ -453,8 +524,8 @@ async fn counters_claims_scripts_and_sets_work_on_the_cluster() { .await .unwrap(); assert_eq!(reply, redis::Value::Okay); - assert_eq!(cache.async_get_ttl("scripted").await.unwrap(), Some(5)); - let evaluated: redis::Value = cache + assert_eq!(cache.async_get_ttl("scripted").await.unwrap(), seconds(5)); + let evaluated = cache .async_eval( "return redis.call('GET', KEYS[1])".into(), vec!["scripted".into()], @@ -472,13 +543,13 @@ async fn counters_claims_scripts_and_sets_work_on_the_cluster() { RedisArg::Bytes(b"a".to_vec()), RedisArg::Bytes(b"b".to_vec()) ], - Some(Duration::from_secs(9)), + seconds(9), ) .await .unwrap(), 2 ); - assert_eq!(cache.async_get_ttl("members").await.unwrap(), Some(9)); + assert_eq!(cache.async_get_ttl("members").await.unwrap(), seconds(9)); let result = cache.test_connection().await.unwrap(); assert_eq!(result.status, CacheConnectionStatus::Success); diff --git a/litellm-rust/crates/cache-redis/tests/contract.rs b/litellm-rust/crates/cache-redis/tests/contract.rs new file mode 100644 index 00000000000..85d043b90b2 --- /dev/null +++ b/litellm-rust/crates/cache-redis/tests/contract.rs @@ -0,0 +1,95 @@ +//! The shared cache contracts, run against the in-process fake connection and, when +//! `LITELLM_TEST_REDIS_CLUSTER_NODES` is set, against a live Redis Cluster. + +mod support; + +use std::time::Duration; + +use litellm_cache::{ExactCacheContext, JsonCodec}; +use litellm_cache_testing as contract; +use rstest::rstest; +use serde_json::json; +use support::{JsonCache, cluster_cache, fake_cache}; + +const PREFIX: &str = "contract:"; + +#[derive(Clone, Copy, Debug)] +enum Contract { + HitAndMiss, + SyncAsyncEquivalence, + OverwriteReplaces, + PipelineWritesEveryEntry, + BatchPreservesOrder, + DeleteRemovesKey, + FlushClears, + CounterAccumulates, +} + +#[derive(Clone, Copy, Debug)] +enum Server { + Fake, + Cluster, +} + +async fn check(contract: Contract, cache: &JsonCache) +where + C: redis::ConnectionLike + Send + 'static, +{ + let context = ExactCacheContext::default(); + match contract { + Contract::HitAndMiss => { + contract::hit_and_miss(cache, context, PREFIX, json!({"answer": 42})).await + } + Contract::SyncAsyncEquivalence => { + contract::sync_async_equivalence(cache, context, PREFIX, json!("first"), json!([2])) + .await + } + Contract::OverwriteReplaces => { + contract::overwrite_replaces(cache, context, PREFIX, json!(1), json!({"b": 2})).await + } + Contract::PipelineWritesEveryEntry => { + contract::pipeline_writes_every_entry( + cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await + } + Contract::BatchPreservesOrder => { + contract::batch_preserves_order(cache, context, PREFIX, json!("first"), json!(2)).await + } + Contract::DeleteRemovesKey => { + contract::delete_removes_key(cache, context, PREFIX, json!("value")).await + } + Contract::FlushClears => { + contract::flush_clears(cache, context, PREFIX, json!("value")).await + } + Contract::CounterAccumulates => contract::counter_accumulates(cache, context, PREFIX).await, + } +} + +#[rstest] +#[case::hit_and_miss(Contract::HitAndMiss)] +#[case::sync_async_equivalence(Contract::SyncAsyncEquivalence)] +#[case::overwrite_replaces(Contract::OverwriteReplaces)] +#[case::pipeline_writes_every_entry(Contract::PipelineWritesEveryEntry)] +#[case::batch_preserves_order(Contract::BatchPreservesOrder)] +#[case::delete_removes_key(Contract::DeleteRemovesKey)] +#[case::flush_clears(Contract::FlushClears)] +#[case::counter_accumulates(Contract::CounterAccumulates)] +#[tokio::test] +async fn redis_satisfies_the_cache_contract( + #[case] contract: Contract, + #[values(Server::Fake, Server::Cluster)] server: Server, +) { + match server { + Server::Fake => check(contract, &fake_cache("contract")).await, + Server::Cluster => { + let label = format!("{contract:?}"); + if let Some(cache) = cluster_cache(&label, Duration::from_secs(120), JsonCodec::new()) { + check(contract, &cache).await; + } + } + } +} diff --git a/litellm-rust/crates/cache-redis/tests/support/mod.rs b/litellm-rust/crates/cache-redis/tests/support/mod.rs new file mode 100644 index 00000000000..bff1e618582 --- /dev/null +++ b/litellm-rust/crates/cache-redis/tests/support/mod.rs @@ -0,0 +1,231 @@ +#![allow(dead_code)] + +use std::{ + collections::BTreeMap, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use litellm_cache::{CacheCodec, Error, JsonCodec}; +use litellm_cache_redis::{RedisCache, RedisNode, RedisTopology}; + +/// Encodes a byte behind a tag, so a value written with another tag decodes as invalid. +pub struct TaggedByteCodec(pub u8); + +impl CacheCodec for TaggedByteCodec { + type Value = u8; + + fn encode(&self, value: &u8) -> Result, Error> { + if *value > 127 { + return Err(Error::InvalidEntry); + } + Ok(vec![self.0, *value]) + } + + fn decode(&self, bytes: &[u8]) -> Result { + match bytes { + [tag, value] if *tag == self.0 => Ok(*value), + _ => Err(Error::InvalidEntry), + } + } +} + +/// A stateful in-process stand-in for a Redis server that understands the string commands the +/// shared contracts exercise, so they run without a live server. TTLs are accepted and ignored. +#[derive(Default)] +pub struct FakeRedis { + strings: BTreeMap, Vec>, +} + +impl FakeRedis { + fn run(&mut self, command: Vec>) -> redis::RedisResult { + let name = String::from_utf8_lossy(&command[0]).to_ascii_uppercase(); + let args = &command[1..]; + Ok(match name.as_str() { + "PING" => redis::Value::SimpleString("PONG".into()), + "SET" | "SETEX" => { + let value = if name == "SET" { &args[1] } else { &args[2] }; + self.strings.insert(args[0].clone(), value.clone()); + redis::Value::Okay + } + "GET" => self.get(&args[0]), + "MGET" => redis::Value::Array(args.iter().map(|key| self.get(key)).collect()), + "DEL" => { + let removed = args + .iter() + .filter(|key| self.strings.remove(*key).is_some()) + .count(); + redis::Value::Int(removed as i64) + } + "SCAN" => { + let pattern = &args[2]; + let keys = self + .strings + .keys() + .filter(|key| glob(pattern, key)) + .map(|key| redis::Value::BulkString(key.clone())) + .collect(); + redis::Value::Array(vec![ + redis::Value::BulkString(b"0".to_vec()), + redis::Value::Array(keys), + ]) + } + "EVAL" if args[0].windows(11).any(|window| window == b"INCRBYFLOAT") => { + self.increment_by_float(&args[2], &args[3]) + } + "INCRBYFLOAT" => self.increment_by_float(&args[0], &args[1]), + _ => { + return Err(redis::RedisError::from(( + redis::ErrorKind::Client, + "unsupported command", + name, + ))); + } + }) + } + + fn get(&self, key: &[u8]) -> redis::Value { + self.strings.get(key).map_or(redis::Value::Nil, |value| { + redis::Value::BulkString(value.clone()) + }) + } + + fn increment_by_float(&mut self, key: &[u8], amount: &[u8]) -> redis::Value { + let current = self + .strings + .get(key) + .map_or(0.0, |value| parse_float(value)); + let total = format!("{}", current + parse_float(amount)); + self.strings + .insert(key.to_vec(), total.clone().into_bytes()); + redis::Value::BulkString(total.into_bytes()) + } +} + +fn parse_float(bytes: &[u8]) -> f64 { + std::str::from_utf8(bytes).unwrap().parse().unwrap() +} + +/// Redis `MATCH` globbing for `*`, `?` and backslash escapes. +fn glob(pattern: &[u8], key: &[u8]) -> bool { + match pattern.split_first() { + None => key.is_empty(), + Some((b'*', rest)) => (0..=key.len()).any(|skip| glob(rest, &key[skip..])), + Some((b'?', rest)) => !key.is_empty() && glob(rest, &key[1..]), + Some((b'\\', [escaped, rest @ ..])) => { + key.first() == Some(escaped) && glob(rest, &key[1..]) + } + Some((literal, rest)) => key.first() == Some(literal) && glob(rest, &key[1..]), + } +} + +/// Splits RESP request bytes into the commands they carry. +fn commands(mut bytes: &[u8]) -> Vec>> { + fn line<'a>(bytes: &mut &'a [u8]) -> &'a [u8] { + let end = bytes + .windows(2) + .position(|window| window == b"\r\n") + .unwrap(); + let (line, rest) = bytes.split_at(end); + *bytes = &rest[2..]; + line + } + fn length(line: &[u8]) -> usize { + std::str::from_utf8(&line[1..]).unwrap().parse().unwrap() + } + let mut commands = Vec::new(); + while !bytes.is_empty() { + let count = length(line(&mut bytes)); + let command = (0..count) + .map(|_| { + let size = length(line(&mut bytes)); + let (argument, rest) = bytes.split_at(size); + bytes = &rest[2..]; + argument.to_vec() + }) + .collect(); + commands.push(command); + } + commands +} + +impl redis::ConnectionLike for FakeRedis { + fn req_packed_command(&mut self, cmd: &[u8]) -> redis::RedisResult { + let command = commands(cmd).into_iter().next().unwrap(); + self.run(command) + } + + fn req_packed_commands( + &mut self, + cmd: &[u8], + offset: usize, + count: usize, + ) -> redis::RedisResult> { + let replies = commands(cmd) + .into_iter() + .map(|command| self.run(command)) + .collect::>>()?; + Ok(replies.into_iter().skip(offset).take(count).collect()) + } + + fn get_db(&self) -> i64 { + 0 + } + + fn check_connection(&mut self) -> bool { + true + } + + fn is_open(&self) -> bool { + true + } +} + +pub type JsonCache = RedisCache, C>; + +pub fn fake_cache(namespace: &str) -> JsonCache { + RedisCache::with_connection(FakeRedis::default(), None, JsonCodec::new()) + .with_namespace(Some(namespace.into())) +} + +/// Startup nodes from `LITELLM_TEST_REDIS_CLUSTER_NODES` (`host:port,host:port`); tests that +/// need a live cluster skip when it is unset. +pub fn cluster_topology() -> Option { + let nodes = std::env::var("LITELLM_TEST_REDIS_CLUSTER_NODES").ok()?; + let startup_nodes = nodes + .split(',') + .map(|node| { + let (host, port) = node.trim().rsplit_once(':').expect("host:port"); + RedisNode { + host: host.to_string(), + port: port.parse().expect("port"), + } + }) + .collect(); + Some(RedisTopology::Cluster { startup_nodes }) +} + +pub fn cluster_url() -> String { + std::env::var("LITELLM_TEST_REDIS_CLUSTER_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:7000".into()) +} + +pub fn unique_namespace(label: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("cluster-test:{label}:{nanos}") +} + +pub fn cluster_cache( + label: &str, + default_ttl: Duration, + codec: S, +) -> Option> { + let topology = cluster_topology()?; + Some( + RedisCache::connect(&cluster_url(), &topology, Some(default_ttl), codec) + .expect("cluster connection") + .with_namespace(Some(unique_namespace(label))), + ) +} diff --git a/litellm-rust/crates/cache-response/Cargo.toml b/litellm-rust/crates/cache-response/Cargo.toml index 04affb9872d..42a1afb2ba0 100644 --- a/litellm-rust/crates/cache-response/Cargo.toml +++ b/litellm-rust/crates/cache-response/Cargo.toml @@ -17,4 +17,5 @@ litellm-cache-memory.workspace = true litellm-cache-redis.workspace = true redis = "1.7.0" redis-test = "1.0.4" +rstest.workspace = true tokio.workspace = true diff --git a/litellm-rust/crates/cache-response/README.md b/litellm-rust/crates/cache-response/README.md index d048afb69f8..dbad474c9e7 100644 --- a/litellm-rust/crates/cache-response/README.md +++ b/litellm-rust/crates/cache-response/README.md @@ -1,14 +1,16 @@ -# Response cache foundation +# Response cache `ResponseCache` adds request keys, independent read/write controls, response envelopes, and freshness checks to any `B: BaseCache` ## Ownership -`litellm-cache` defines typed storage and codec traits. Memory and Redis implement those traits without depending on response policy. Other consumers can store their own value types using the same backend implementations +`litellm-cache` defines typed storage, codec, and capability traits. `BaseCache` is only get, set, TTL, and pipeline writes. Everything else is an optional capability a backend implements only where its Python class defines the method: `DisconnectCache`, `ConnectionCache` (`test_connection`), `PingCache`, `BatchCache`, `DeleteCache`, `FlushCache`, counters, queues, TTL, scan, and scripts. Memory, Redis, disk, S3, GCS, and Azure Blob implement those traits without depending on response policy, so other consumers can store their own value types in the same backends + +Semantic backends (Redis, Valkey, Qdrant) are generic over their embedder and codec, and share one prompt and embedding contract from `litellm_cache::semantic`. They take a `SemanticCacheContext`, so `ResponseCache` drives them the same way it drives exact backends `litellm-cache-response` owns response keys, controls, entries, the Python-compatible response codec, and `WriteBuffer`, the backend-neutral deferred-write policy. It has no runtime dependency on a specific cache backend or Python -The Python bridge constructs backends and selects them through its private `NativeResponseCache` enum, which only dispatches. Generic Rust callers inject their backend directly. A native gateway can construct the same generic response service in its own host +`ExactResponseCache` is the object-safe view of a `ResponseCache` over an exact backend. `ConnectionProbe` is the object-safe `test_connection`, implemented only when the backend implements `ConnectionCache`, so a host holds one next to its `ExactResponseCache` and reports the operation as unsupported otherwise, as Python's `BaseCache` does. Lookup, store, batch, and flush never require it ## Native Rust use @@ -28,36 +30,22 @@ cache.store(&request, json!({"answer": 7}), now)?; assert_eq!(cache.async_lookup(&request, now).await?, Some(json!({"answer": 7}))); ``` -For Redis, inject `RedisCache::new(url, ttl, ResponseCacheCodec)` instead. Namespaces are optional and existing namespace prefixes are preserved. Sync operations check out independent connections from a bounded pool, while async callers, including counters and claims, move that blocking work off the executor. The pool skips the checkout PING and instead discards any connection whose command failed +For Redis, inject `RedisCache::new(url, ttl, ResponseCacheCodec)` instead. Namespaces are optional and existing namespace prefixes are preserved Callers supply Unix time for response freshness. Backend TTL uses its own clock. A read can reject an entry through `max_age` even while the backend still retains it -## Python integration boundary +## Python integration -The extension keeps a private test harness for memory and Redis single and batch response lookup and storage. Batch lookup returns ordered values plus missing indices for embedding partial-hit wiring. No bridge-only cache type is part of the public API +The bridge activates backends through the Rust catalog in `litellm/rust_bridge/catalog.py`. Every cache rule ships as `PYTHON_ONLY`, so SDK, Router, and proxy calls stay on Python and construct no native cache resources until a rule is changed -The bridge also exposes a production-shaped response cache runtime selected through the Rust catalog. Its shipped rule set is empty, so current SDK, Router, and proxy calls stay on Python and do not construct native cache resources. Tests can inject a rule and build the native memory runtime from an ordinary Python `Cache` configuration without changing the legacy cache classes +When a rule selects a backend, the Python `Cache` facade builds the native runtime from its own configuration and routes its storage calls (sync and async lookup and store, and pipelined batch store) to it. Stream replay, embedding partial-hit merging, response reconstruction, and callbacks stay in Python on top of that native store. The Python backend object remains for its direct API Object responses are written as they are, and every other response shape is written as a serialized string, which is the pair of shapes Python reads. A string on the wire is therefore always a serialized response, so string-valued responses round trip. Typed backends such as memory never pass through the codec -The resolver reads the namespace's `cache` attribute each time it resolves. A captured binding retains the selected service for its operation, including background writes. `None` disables caching. Custom Python cache objects keep their original methods, arguments, returned awaitables, exceptions, and caller-task execution - -Python callbacks use the built-in `Cache` API, so a `Cache` subclass works unchanged. A batch lookup takes one original kwargs mapping per request and returns the list of `get_cache` or gathered `async_get_cache` results, while native bindings return `{values, missing_indices}`. A batch store hands the caller's original result to `async_add_cache_pipeline`. `ping` calls `ping`, and a flush goes to the facade's backend - -The private facade test harness checks object identity, method overrides, effective TTL, Redis namespace, memory capacity, and later configuration changes before selecting native execution. Its snapshot includes Redis connection settings, so a later `redis_kwargs` change, including an SSL option, selects Python callback execution. Buffered async writes honor `redis_flush_size`. Public activation must construct the shared native service from the initial Python Redis settings, including `litellm.default_redis_ttl` and SSL options. A buffered entry keeps the time it was produced, and a failed flush drops its batch instead of growing the buffer during an outage. The harness does not migrate entries or replace Python methods. Until activation configures one shared service, the Python facade and native test service can hold separate data. Existing public cache constructors remain on Python - -Native cache handles must be recreated after fork. The bridge releases the GIL around native operations, and Redis runs blocking connection operations off the async executor. Native errors propagate to the host, which owns the existing fail-open and logging policy - -The Redis backend also provides the primitives needed to preserve its direct Python surface later: TLS URLs, ping, bulk delete, counter batches, TTL, scan, set membership, raw queue push and pop, queue and counter pipelines, counter floor and maximum operations, script evaluation, client information, namespaced flush, and full flush. These are backend operations only and are not exported to Python by this PR. Memory provides TTL, oldest-key, and counter-pipeline operations +Native cache handles must be recreated after fork. Native errors propagate to the host, which owns the existing fail-open and logging policy ## Adding another backend -Implement `BaseCache` for the backend with its associated value type, and accept a `CacheCodec` when wire serialization is needed. `ResponseCache` then works without another response implementation. Add a concrete bridge enum variant and constructor only when exposing that backend to Python +Implement `BaseCache` for the backend with its associated value type and the capability traits its Python class supports, and accept a `CacheCodec` when wire serialization is needed. `ResponseCache` then works without another response implementation -Verify typed values, TTL precedence, missing entries, serialization failures, namespaces, batch ordering, and sync/async behavior. Run response fixtures with `ResponseCacheCodec`, including both Python envelope encodings, before enabling a public facade - -## Follow-up scope - -Public SDK, Router, and proxy activation still need constructor parity, stream replay, embedding partial-batch integration, response reconstruction, callback scheduling, and failure-policy integration. This foundation does not switch those request paths - -Redis cluster, disk, and cloud stores remain follow-ups. Semantic backends plug in through `SemanticCacheContext`, which carries the prompt inputs and metadata alongside the cache TTL. The generic dual cache takes read, write, and remote-failure policies, runs its async operations through the async L2 methods, and provides L2-first counters and atomic affinity claims. Errors propagate by default, and `RemoteFailurePolicy::UseLocal` opts key-value operations and claims into the local tier when L2 is unavailable. Claims compare decoded values, so a pin written by Python still matches. Public Router integration remains follow-up work. Reservations and pubsub still need explicit capabilities owned by their consuming features. Adding a cache backend does not establish those guarantees +Run the `litellm-cache-testing` contract checks the backend's capabilities allow, and run response fixtures with `ResponseCacheCodec`, including both Python envelope encodings, before adding a catalog rule diff --git a/litellm-rust/crates/cache-response/src/exact.rs b/litellm-rust/crates/cache-response/src/exact.rs index f5e86b2598c..16b79e4b11b 100644 --- a/litellm-rust/crates/cache-response/src/exact.rs +++ b/litellm-rust/crates/cache-response/src/exact.rs @@ -1,7 +1,8 @@ use std::{future::Future, pin::Pin, time::Duration}; use litellm_cache::{ - BaseCache, BatchCache, CacheConnectionResult, Error, ExactCacheContext, FlushCache, + BaseCache, BatchCache, CacheConnectionResult, ConnectionCache, Error, ExactCacheContext, + FlushCache, }; use serde_json::Value; @@ -61,10 +62,25 @@ pub trait ExactResponseCache: Send + Sync { ) -> BoxFuture<'a, Result<(), Error>>; fn async_flush<'a>(&'a self) -> BoxFuture<'a, Result<(), Error>>; +} +/// Object-safe `test_connection` for the exact backends whose Python class defines it. Hosts hold +/// one next to their `ExactResponseCache` when the backend has it, and report the operation as +/// unsupported otherwise, as Python's `BaseCache.test_connection` does. +pub trait ConnectionProbe: Send + Sync { fn test_connection<'a>(&'a self) -> BoxFuture<'a, Result>; } +impl ConnectionProbe for ResponseCache +where + B: ConnectionCache, + B::Context: Default + PartialEq, +{ + fn test_connection<'a>(&'a self) -> BoxFuture<'a, Result> { + Box::pin(ResponseCache::test_connection(self)) + } +} + impl ExactResponseCache for ResponseCache where B: BaseCache + BatchCache + FlushCache, @@ -141,8 +157,4 @@ where fn async_flush<'a>(&'a self) -> BoxFuture<'a, Result<(), Error>> { Box::pin(ResponseCache::async_flush(self)) } - - fn test_connection<'a>(&'a self) -> BoxFuture<'a, Result> { - Box::pin(ResponseCache::test_connection(self)) - } } diff --git a/litellm-rust/crates/cache-response/src/lib.rs b/litellm-rust/crates/cache-response/src/lib.rs index ab9867ac8db..a6a4bb3eb64 100644 --- a/litellm-rust/crates/cache-response/src/lib.rs +++ b/litellm-rust/crates/cache-response/src/lib.rs @@ -12,5 +12,5 @@ pub use caching::{ }; pub use codec::ResponseCacheCodec; pub use embedding::PartialHits; -pub use exact::ExactResponseCache; +pub use exact::{ConnectionProbe, ExactResponseCache}; pub use response::{ResponseCache, ResponseCacheRequest}; diff --git a/litellm-rust/crates/cache-response/src/response.rs b/litellm-rust/crates/cache-response/src/response.rs index 5088402f125..e761c7157db 100644 --- a/litellm-rust/crates/cache-response/src/response.rs +++ b/litellm-rust/crates/cache-response/src/response.rs @@ -1,7 +1,9 @@ use std::{sync::Arc, time::Duration}; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, CacheConnectionResult, CacheContext, Error, FlushCache, + BaseCache, BatchCache, BatchEntry, CacheConnectionResult, CacheContext, ConnectionCache, Error, + FlushCache, + semantic::{SemanticCache, SemanticLookup}, }; use serde_json::Value; @@ -78,7 +80,10 @@ where self.backend.async_flush_cache().await } - pub async fn test_connection(&self) -> Result { + pub async fn test_connection(&self) -> Result + where + B: ConnectionCache, + { self.backend.test_connection().await } @@ -121,6 +126,43 @@ where Ok(Self::fresh_or_miss(entry, now, request.max_age)) } + /// `lookup` plus the similarity the semantic backend reports. Freshness applies to the + /// value only: Python stamps the similarity before its max-age check. + pub fn lookup_semantic( + &self, + request: &ResponseCacheRequest, + now: Duration, + ) -> Result, Error> + where + B: SemanticCache, + { + if !request.controls.reads() { + return Ok(SemanticLookup::miss(None)); + } + let lookup = self + .backend + .get_cache_with_similarity(&cache_key(&request.key), &request.context); + Self::fresh_semantic(lookup, now, request.max_age) + } + + pub async fn async_lookup_semantic( + &self, + request: &ResponseCacheRequest, + now: Duration, + ) -> Result, Error> + where + B: SemanticCache, + { + if !request.controls.reads() { + return Ok(SemanticLookup::miss(None)); + } + let lookup = self + .backend + .async_get_cache_with_similarity(&cache_key(&request.key), &request.context) + .await; + Self::fresh_semantic(lookup, now, request.max_age) + } + pub fn lookup_batch( &self, requests: &[ResponseCacheRequest], @@ -290,6 +332,21 @@ where Ok(PartialHits::new(values)) } + fn fresh_semantic( + lookup: Result, Error>, + now: Duration, + max_age: Option, + ) -> Result, Error> { + match lookup { + Ok(lookup) => Ok(SemanticLookup { + value: Self::fresh_or_miss(lookup.value, now, max_age), + similarity: lookup.similarity, + }), + Err(Error::InvalidEntry) => Ok(SemanticLookup::miss(None)), + Err(error) => Err(error), + } + } + fn fresh_or_miss( entry: Option, now: Duration, diff --git a/litellm-rust/crates/cache-response/tests/caching.rs b/litellm-rust/crates/cache-response/tests/caching.rs index 0e8ce9b3b1d..93c2eb3d16d 100644 --- a/litellm-rust/crates/cache-response/tests/caching.rs +++ b/litellm-rust/crates/cache-response/tests/caching.rs @@ -1,90 +1,174 @@ use litellm_cache_response::{ CacheControls, CacheKeyContext, CacheKeyField, CacheKeyInput, cache_key, get_cache_key, + should_use_cache, }; +use rstest::rstest; use sha2::{Digest, Sha256}; -#[test] -fn keys_match_python_order_groups_files_presets_and_namespaces() { - let mut input = CacheKeyInput { - fields: vec![ - CacheKeyField { - name: "model".into(), - value: Some("deployment".into()), - api_parameter: true, - internal_parameter: false, - }, - CacheKeyField { - name: "file".into(), - value: None, - api_parameter: true, - internal_parameter: false, - }, - ], - namespace: Some("team".into()), - ..Default::default() - }; +fn field(name: &str, value: Option<&str>) -> CacheKeyField { + CacheKeyField { + name: name.into(), + value: value.map(str::to_owned), + api_parameter: true, + internal_parameter: false, + } +} + +fn hash(preimage: &[u8]) -> String { + format!("{:x}", Sha256::digest(preimage)) +} + +#[rstest] +#[case::caching_group_and_checksum( CacheKeyContext { model_group: Some("group".into()), caching_groups: vec![(vec!["group".into()], "['group']".into())], file_checksum: Some("checksum".into()), ..Default::default() - } - .apply(&mut input); - assert_eq!( - cache_key(&input), - format!( - "team:{:x}", - Sha256::digest(b"model: ['group']file: checksum") - ) - ); - input.preset = Some("preset".into()); + }, + Some("team"), + "team:", + b"model: ['group']file: checksum".as_slice(), +)] +#[case::model_group_outside_caching_groups( + CacheKeyContext { + model_group: Some("group".into()), + caching_groups: vec![(vec!["other".into()], "['other']".into())], + file_object_name: Some("object".into()), + ..Default::default() + }, + None, + "", + b"model: groupfile: object".as_slice(), +)] +#[case::metadata_file_name_before_parameters( + CacheKeyContext { + metadata_file_name: Some("metadata".into()), + parameters_file_name: Some("parameters".into()), + ..Default::default() + }, + Some(""), + "", + b"model: deploymentfile: metadata".as_slice(), +)] +#[case::parameters_file_name_last( + CacheKeyContext { + parameters_file_name: Some("parameters".into()), + ..Default::default() + }, + None, + "", + b"model: deploymentfile: parameters".as_slice(), +)] +#[case::no_context_keeps_the_request_model( + CacheKeyContext::default(), + Some("team"), + "team:", + b"model: deployment".as_slice(), +)] +fn keys_match_python_order_groups_files_and_namespaces( + #[case] context: CacheKeyContext, + #[case] namespace: Option<&str>, + #[case] prefix: &str, + #[case] preimage: &[u8], +) { + let mut input = CacheKeyInput { + fields: vec![field("model", Some("deployment")), field("file", None)], + namespace: namespace.map(str::to_owned), + ..Default::default() + }; + context.apply(&mut input); + let expected = format!("{prefix}{}", hash(preimage)); + assert_eq!(cache_key(&input), expected); + assert_eq!(get_cache_key(&input), expected); +} + +#[rstest] +#[case::api_parameter(true, false, false, true)] +#[case::provider_parameter_when_included(false, false, true, true)] +#[case::provider_parameter_when_excluded(false, false, false, false)] +#[case::internal_parameter_never(false, true, true, false)] +fn keys_hash_api_and_opted_in_provider_parameters( + #[case] api_parameter: bool, + #[case] internal_parameter: bool, + #[case] include_provider_parameters: bool, + #[case] hashed: bool, +) { + let input = CacheKeyInput { + fields: vec![ + field("model", Some("a")), + CacheKeyField { + name: "extra".into(), + value: Some("x".into()), + api_parameter, + internal_parameter, + }, + ], + include_provider_parameters, + ..Default::default() + }; + let preimage: &[u8] = if hashed { + b"model: aextra: x" + } else { + b"model: a" + }; + assert_eq!(cache_key(&input), hash(preimage)); +} + +#[rstest] +#[case::without_namespace(None)] +#[case::with_namespace(Some("team"))] +fn preset_keys_are_used_verbatim(#[case] namespace: Option<&str>) { + let input = CacheKeyInput { + fields: vec![field("model", Some("a"))], + preset: Some("preset".into()), + namespace: namespace.map(str::to_owned), + ..Default::default() + }; + assert_eq!(cache_key(&input), "preset"); assert_eq!(get_cache_key(&input), "preset"); } -#[test] -fn cache_controls_honor_default_modes_and_directives() { - let enabled = CacheControls { - supported_call_type: true, - configured: true, - default_on: true, - ..Default::default() - }; - assert!(enabled.reads()); - assert!(enabled.writes()); - assert!( - !CacheControls { - default_on: false, - ..enabled - } - .reads() - ); - assert!( - CacheControls { - default_on: false, - use_cache: true, - ..enabled - } - .reads() - ); - assert!( - !CacheControls { - no_cache: true, - ..enabled - } - .reads() - ); - assert!( - !CacheControls { - no_store: true, - ..enabled - } - .writes() - ); - assert!( - !CacheControls { - caching: Some(false), - ..enabled - } - .writes() - ); +const ENABLED: CacheControls = CacheControls { + supported_call_type: true, + configured: true, + native_backend: false, + default_on: true, + caching: None, + no_cache: false, + no_store: false, + use_cache: false, +}; + +#[rstest] +#[case::enabled(ENABLED, true, true)] +#[case::default_off(CacheControls { default_on: false, ..ENABLED }, false, false)] +#[case::default_off_with_use_cache( + CacheControls { default_on: false, use_cache: true, ..ENABLED }, + true, + true +)] +#[case::no_cache(CacheControls { no_cache: true, ..ENABLED }, false, true)] +#[case::no_store(CacheControls { no_store: true, ..ENABLED }, true, false)] +#[case::no_cache_and_no_store( + CacheControls { no_cache: true, no_store: true, ..ENABLED }, + false, + false +)] +#[case::caching_disabled(CacheControls { caching: Some(false), ..ENABLED }, false, false)] +#[case::caching_enabled(CacheControls { caching: Some(true), ..ENABLED }, true, true)] +#[case::unsupported_call_type( + CacheControls { supported_call_type: false, ..ENABLED }, + false, + false +)] +#[case::unconfigured(CacheControls { configured: false, ..ENABLED }, false, false)] +fn cache_controls_honor_default_modes_and_directives( + #[case] controls: CacheControls, + #[case] reads: bool, + #[case] writes: bool, +) { + assert_eq!(controls.reads(), reads); + assert_eq!(controls.writes(), writes); + assert_eq!(should_use_cache(controls), reads || writes); } diff --git a/litellm-rust/crates/cache-response/tests/codec.rs b/litellm-rust/crates/cache-response/tests/codec.rs new file mode 100644 index 00000000000..4fb5a5094cb --- /dev/null +++ b/litellm-rust/crates/cache-response/tests/codec.rs @@ -0,0 +1,110 @@ +use litellm_cache::{CacheCodec, Error}; +use litellm_cache_response::{CacheEntry, ResponseCacheCodec}; +use rstest::rstest; +use serde_json::{Value, json}; + +fn entry(response: Value) -> CacheEntry { + CacheEntry { + timestamp: Some(100.0), + response, + } +} + +#[rstest] +#[case::python_literal_object( + br#"{'timestamp': 100.0, 'response': {'text': 'hello \\ world', 'flag': True, 'empty': None, 'list': [1, 2.5]}}"#.as_slice(), + json!({"text": "hello \\ world", "flag": true, "empty": null, "list": [1, 2.5]}), +)] +#[case::python_sync_string_response( + br#"{'timestamp': 100.0, 'response': '{"ok": true, "text": "cached"}'}"#.as_slice(), + json!({"ok": true, "text": "cached"}), +)] +#[case::python_literal_string_response( + br#"{'timestamp': 100.0, 'response': "{'ok': True, 'items': (1, 2)}"}"#.as_slice(), + json!({"ok": true, "items": [1, 2]}), +)] +#[case::json_object_response( + br#"{"timestamp":100.0,"response":{"ok":true,"text":"cached"}}"#.as_slice(), + json!({"ok": true, "text": "cached"}), +)] +#[case::json_string_response( + br#"{"timestamp": 100.0, "response": "[1,2]"}"#.as_slice(), + json!([1, 2]), +)] +fn decode_reads_every_python_envelope(#[case] bytes: &[u8], #[case] response: Value) { + assert_eq!(ResponseCacheCodec.decode(bytes).unwrap(), entry(response)); +} + +#[rstest] +#[case::code_is_not_executed(b"__import__('os').system('false')".to_vec())] +#[case::non_numeric_timestamp(b"{'timestamp': 'invalid', 'response': {}}".to_vec())] +#[case::infinite_timestamp(b"{'timestamp': 1e9999, 'response': {}}".to_vec())] +#[case::missing_response(br#"{"timestamp": 100.0}"#.to_vec())] +#[case::unserialized_string_response(br#"{"timestamp": 100.0, "response": "not serialized"}"#.to_vec())] +#[case::non_utf8(vec![0xff, 0xfe])] +#[case::deep_nesting(format!("{}None{}", "[".repeat(1000), "]".repeat(1000)).into_bytes())] +fn decode_rejects_invalid_entries(#[case] bytes: Vec) { + assert_eq!( + ResponseCacheCodec.decode(&bytes).unwrap_err(), + Error::InvalidEntry + ); +} + +#[rstest] +#[case::nan(f64::NAN)] +#[case::infinity(f64::INFINITY)] +fn encode_rejects_non_finite_timestamps(#[case] timestamp: f64) { + assert_eq!( + ResponseCacheCodec + .encode(&CacheEntry { + timestamp: Some(timestamp), + response: json!({}), + }) + .unwrap_err(), + Error::InvalidEntry + ); +} + +#[rstest] +#[case::object(json!({"choices": [{"text": "cached"}]}), json!({"choices": [{"text": "cached"}]}))] +#[case::array(json!([1, 2]), json!("[1,2]"))] +#[case::number(json!(7), json!("7"))] +#[case::null(json!(null), json!("null"))] +#[case::string(json!("hello world"), json!("\"hello world\""))] +#[case::numeric_string(json!("123"), json!("\"123\""))] +#[case::null_string(json!("null"), json!("\"null\""))] +fn encode_writes_python_readable_envelopes_that_round_trip( + #[case] response: Value, + #[case] wire_response: Value, +) { + let wire = ResponseCacheCodec.encode(&entry(response.clone())).unwrap(); + assert_eq!( + serde_json::from_slice::(&wire).unwrap(), + json!({"timestamp": 100.0, "response": wire_response}) + ); + assert_eq!(ResponseCacheCodec.decode(&wire).unwrap(), entry(response)); +} + +#[rstest] +fn object_entries_preserve_the_existing_json_representation() { + let entry = CacheEntry { + timestamp: Some(123.0), + response: json!({"choices": [{"text": "cached"}]}), + }; + let bytes = ResponseCacheCodec.encode(&entry).unwrap(); + assert_eq!(bytes, serde_json::to_vec(&entry).unwrap()); + assert_eq!(ResponseCacheCodec.decode(&bytes).unwrap(), entry); +} + +#[rstest] +#[case::json(br#"{"choices": [{"text": "legacy"}]}"#.as_slice())] +#[case::python_literal(br#"{'choices': [{'text': 'legacy'}]}"#.as_slice())] +fn values_without_timestamps_decode_as_bare_responses(#[case] bytes: &[u8]) { + assert_eq!( + ResponseCacheCodec.decode(bytes).unwrap(), + CacheEntry { + timestamp: None, + response: json!({"choices": [{"text": "legacy"}]}), + } + ); +} diff --git a/litellm-rust/crates/cache-response/tests/connection.rs b/litellm-rust/crates/cache-response/tests/connection.rs new file mode 100644 index 00000000000..bc24cf56846 --- /dev/null +++ b/litellm-rust/crates/cache-response/tests/connection.rs @@ -0,0 +1,123 @@ +mod support; + +use std::{sync::Arc, time::Duration}; + +use litellm_cache::CacheConnectionStatus; +use litellm_cache_memory::InMemoryCache; +use litellm_cache_response::{ + CacheEntry, ConnectionProbe, ExactResponseCache, ResponseCache, ResponseCacheRequest, +}; +use redis_test::MockCmd; +use rstest::rstest; +use serde_json::json; +use support::{keyed, memory, redis, request}; + +#[rstest] +#[case::reachable( + Ok("PONG"), + CacheConnectionStatus::Success, + "Redis connection test successful", + false +)] +#[case::unexpected_reply( + Ok("NOPE"), + CacheConnectionStatus::Failed, + "Redis ping returned False", + false +)] +#[case::connection_refused( + Err(redis::RedisError::from((redis::ErrorKind::Io, "connection refused"))), + CacheConnectionStatus::Failed, + "Redis connection failed:", + true +)] +#[tokio::test] +async fn connection_backends_are_reachable_as_a_probe( + #[case] reply: redis::RedisResult<&'static str>, + #[case] status: CacheConnectionStatus, + #[case] message: &str, + #[case] has_error: bool, +) { + let probe: Arc = + Arc::new(redis(vec![MockCmd::new(redis::cmd("PING"), reply)], None)); + + let result = probe.test_connection().await.unwrap(); + assert_eq!(result.status, status); + assert!(result.message.starts_with(message), "{}", result.message); + assert_eq!(result.error.is_some(), has_error); +} + +#[rstest] +#[tokio::test] +async fn one_service_serves_both_the_exact_cache_and_its_probe(request: ResponseCacheRequest) { + let service = Arc::new(redis( + vec![ + MockCmd::new(redis::cmd("PING"), Ok("PONG")), + MockCmd::new( + redis::cmd("GET").arg("tenant:key"), + Ok(br#"{"timestamp":100.0,"response":{"ok":true}}"#.to_vec()), + ), + ], + Some("tenant"), + )); + let probe: Arc = service.clone(); + let exact: Arc = service; + + assert_eq!( + probe.test_connection().await.unwrap().status, + CacheConnectionStatus::Success + ); + assert_eq!( + exact + .async_lookup(&request, Duration::from_secs(100)) + .await + .unwrap(), + Some(json!({"ok": true})) + ); +} + +/// The in-memory backend has no `test_connection`, as in Python, and still serves every response +/// operation. +#[rstest] +#[tokio::test] +async fn backends_without_a_connection_test_serve_every_response_operation( + #[from(memory)] service: Arc>>, + request: ResponseCacheRequest, +) { + let cache: Arc = service; + let now = Duration::from_secs(100); + let other = keyed("tenant:other"); + let missing = keyed("tenant:missing"); + + assert_eq!(cache.default_ttl(), Some(Duration::from_secs(600))); + cache.store(&request, json!({"v": 1}), now).unwrap(); + assert_eq!(cache.lookup(&request, now).unwrap(), Some(json!({"v": 1}))); + cache + .async_store(&other, json!({"v": 2}), now) + .await + .unwrap(); + assert_eq!( + cache.async_lookup(&other, now).await.unwrap(), + Some(json!({"v": 2})) + ); + + let requests = [request.clone(), missing.clone(), other.clone()]; + let partial = cache.lookup_batch(&requests, now).unwrap(); + assert_eq!( + partial.values, + vec![Some(json!({"v": 1})), None, Some(json!({"v": 2}))] + ); + assert_eq!(partial.missing_indices, vec![1]); + + cache + .async_store_batch(vec![(missing.clone(), json!({"v": 3}))], now) + .await + .unwrap(); + let partial = cache.async_lookup_batch(&requests, now).await.unwrap(); + assert!(partial.missing_indices.is_empty()); + assert_eq!(partial.values[1], Some(json!({"v": 3}))); + + cache.async_flush().await.unwrap(); + let partial = cache.async_lookup_batch(&requests, now).await.unwrap(); + assert_eq!(partial.missing_indices, vec![0, 1, 2]); +} diff --git a/litellm-rust/crates/cache-response/tests/response.rs b/litellm-rust/crates/cache-response/tests/response.rs index dcfc0301148..ec5e16f1367 100644 --- a/litellm-rust/crates/cache-response/tests/response.rs +++ b/litellm-rust/crates/cache-response/tests/response.rs @@ -1,3 +1,5 @@ +mod support; + use std::{ sync::{ Arc, Mutex, @@ -7,32 +9,22 @@ use std::{ }; use litellm_cache::{ - BaseCache, CacheCodec, CacheConnectionResult, CacheConnectionStatus, Error, - SemanticCacheContext, + BaseCache, Error, SemanticCacheContext, + semantic::{SemanticCache, SemanticLookup}, }; use litellm_cache_memory::InMemoryCache; -use litellm_cache_redis::RedisCache; use litellm_cache_response::{ - CacheEntry, CacheKeyField, CacheKeyInput, ResponseCache, ResponseCacheCodec, - ResponseCacheRequest, WriteBuffer, + CacheControls, CacheEntry, CacheKeyField, CacheKeyInput, ResponseCache, ResponseCacheRequest, + WriteBuffer, cache_key, }; -use redis_test::{MockCmd, MockRedisConnection}; -use serde_json::json; +use redis_test::MockCmd; +use rstest::rstest; +use serde_json::{Value, json}; +use support::{keyed, memory, redis, request}; -fn memory() -> Arc>> { - Arc::new(ResponseCache::new(Arc::new(InMemoryCache::new( - Some(8), - Some(Duration::from_secs(600)), - )))) -} - -fn request() -> ResponseCacheRequest { - ResponseCacheRequest::new(CacheKeyInput { - preset: Some("tenant:key".into()), - ..Default::default() - }) -} +type Memory = Arc>>; +#[derive(Default)] struct SemanticBackend { entries: Mutex>, contexts: Mutex>, @@ -67,50 +59,133 @@ impl BaseCache for SemanticBackend { .find(|(entry_key, _)| entry_key == key) .map(|(_, entry)| entry.clone())) } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - Ok(CacheConnectionResult { - status: CacheConnectionStatus::Success, - message: "ok".into(), - error: None, - }) - } } -#[test] -fn semantic_context_reaches_backend_for_store_and_lookup() { - let backend = Arc::new(SemanticBackend { - entries: Mutex::new(Vec::new()), - contexts: Mutex::new(Vec::new()), - }); +#[rstest] +#[tokio::test] +async fn semantic_context_reaches_backend_for_store_and_lookup( + request: ResponseCacheRequest, + #[values(false, true)] asynchronous: bool, +) { + let backend = Arc::new(SemanticBackend::default()); let cache = ResponseCache::new(backend.clone()); let context = SemanticCacheContext { messages: Some(json!([{"role": "user", "content": "hello"}])), ..Default::default() }; - let request = request().with_context(context.clone()); + let request = request.with_context(context.clone()); let response = json!({"answer": 42}); + let now = Duration::from_secs(100); - cache - .store(&request, response.clone(), Duration::from_secs(100)) - .unwrap(); + let hit = if asynchronous { + cache + .async_store(&request, response.clone(), now) + .await + .unwrap(); + cache.async_lookup(&request, now).await.unwrap() + } else { + cache.store(&request, response.clone(), now).unwrap(); + cache.lookup(&request, now).unwrap() + }; - assert_eq!( - cache.lookup(&request, Duration::from_secs(100)).unwrap(), - Some(response) - ); + assert_eq!(hit, Some(response)); assert_eq!( backend.contexts.lock().unwrap().as_slice(), &[context.clone(), context] ); } +/// A semantic backend that answers every read with one fixed lookup. +struct ScoredBackend(Result, Error>); + +impl BaseCache for ScoredBackend { + type Value = CacheEntry; + type Context = SemanticCacheContext; + + fn get_ttl(&self, _: &Self::Context) -> Option { + None + } + + fn set_cache(&self, _: &str, _: Self::Value, _: &Self::Context) -> Result<(), Error> { + Ok(()) + } + + fn get_cache(&self, key: &str, context: &Self::Context) -> Result, Error> { + self.get_cache_with_similarity(key, context) + .map(|lookup| lookup.value) + } +} + +impl SemanticCache for ScoredBackend { + fn get_cache_with_similarity( + &self, + _: &str, + _: &Self::Context, + ) -> Result, Error> { + self.0.clone() + } + + async fn async_get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + self.get_cache_with_similarity(key, context) + } +} + +fn scored(timestamp: f64, similarity: f64) -> Result, Error> { + Ok(SemanticLookup { + value: Some(CacheEntry { + timestamp: Some(timestamp), + response: json!({"answer": 42}), + }), + similarity: Some(similarity), + }) +} + +#[rstest] +#[case::fresh_hit(scored(95.0, 0.95), true, Ok(SemanticLookup { value: Some(json!({"answer": 42})), similarity: Some(0.95) }))] +#[case::stale_hit_keeps_the_similarity( + scored(50.0, 0.95), + true, + Ok(SemanticLookup::miss(Some(0.95))) +)] +#[case::miss_keeps_the_similarity( + Ok(SemanticLookup::miss(Some(0.4))), + true, + Ok(SemanticLookup::miss(Some(0.4))) +)] +#[case::no_search(Ok(SemanticLookup::miss(None)), true, Ok(SemanticLookup::miss(None)))] +#[case::disabled_reads_skip_the_backend(scored(95.0, 0.95), false, Ok(SemanticLookup::miss(None)))] +#[case::invalid_entry_is_a_miss(Err(Error::InvalidEntry), true, Ok(SemanticLookup::miss(None)))] +#[case::backend_errors_propagate(Err(Error::Unavailable), true, Err(Error::Unavailable))] #[tokio::test] -async fn sync_and_async_consumers_share_keys_ttls_and_freshness() { +async fn semantic_lookup_applies_freshness_to_the_value_only( + #[case] backend: Result, Error>, + #[case] reads: bool, + #[case] expected: Result, Error>, + #[values(false, true)] asynchronous: bool, + request: ResponseCacheRequest, +) { + let cache = ResponseCache::new(Arc::new(ScoredBackend(backend))); + let mut request = request.with_context(SemanticCacheContext::default()); + request.max_age = Some(Duration::from_secs(10)); + request.controls.no_cache = !reads; + let now = Duration::from_secs(100); + + let lookup = if asynchronous { + cache.async_lookup_semantic(&request, now).await + } else { + cache.lookup_semantic(&request, now) + }; + + assert_eq!(lookup, expected); +} + +#[rstest] +#[tokio::test] +async fn sync_and_async_consumers_share_keys_ttls_and_freshness(mut request: ResponseCacheRequest) { let clock = Arc::new(AtomicU64::new(100)); let backend = Arc::new(InMemoryCache::with_clock( Some(8), @@ -121,7 +196,6 @@ async fn sync_and_async_consumers_share_keys_ttls_and_freshness() { }, )); let cache = ResponseCache::new(backend.clone()); - let mut request = request(); request.context.ttl = Some(Duration::from_secs(10)); request.max_age = Some(Duration::from_secs(5)); cache @@ -172,80 +246,164 @@ async fn sync_and_async_consumers_share_keys_ttls_and_freshness() { ); } +#[derive(Clone, Copy, Debug)] +enum Directive { + Plain, + NoCache, + NoStore, + DefaultOff, + UseCache, + CachingOff, + Unsupported, +} + +impl Directive { + fn apply(self, controls: &mut CacheControls) { + match self { + Self::Plain => {} + Self::NoCache => controls.no_cache = true, + Self::NoStore => controls.no_store = true, + Self::DefaultOff => controls.default_on = false, + Self::UseCache => { + controls.default_on = false; + controls.use_cache = true; + } + Self::CachingOff => controls.caching = Some(false), + Self::Unsupported => controls.supported_call_type = false, + } + } +} + +#[rstest] +#[case::plain(Directive::Plain, Directive::Plain, true)] +#[case::no_store_skips_the_write(Directive::NoStore, Directive::Plain, false)] +#[case::no_store_keeps_reads(Directive::Plain, Directive::NoStore, true)] +#[case::no_cache_keeps_writes(Directive::NoCache, Directive::Plain, true)] +#[case::no_cache_skips_the_read(Directive::Plain, Directive::NoCache, false)] +#[case::default_off_skips_the_write(Directive::DefaultOff, Directive::Plain, false)] +#[case::default_off_skips_the_read(Directive::Plain, Directive::DefaultOff, false)] +#[case::use_cache_opts_in_under_default_off(Directive::UseCache, Directive::UseCache, true)] +#[case::caching_off_skips_the_write(Directive::CachingOff, Directive::Plain, false)] +#[case::caching_off_skips_the_read(Directive::Plain, Directive::CachingOff, false)] +#[case::unsupported_call_type_skips_the_write(Directive::Unsupported, Directive::Plain, false)] +#[case::unsupported_call_type_skips_the_read(Directive::Plain, Directive::Unsupported, false)] #[tokio::test] -async fn directives_skip_io_and_keep_reads_and_writes_independent() { - let cache = memory(); - let mut request = request(); +async fn directives_skip_io_and_keep_reads_and_writes_independent( + memory: Memory, + request: ResponseCacheRequest, + #[case] write: Directive, + #[case] read: Directive, + #[case] hit: bool, + #[values(false, true)] asynchronous: bool, +) { let now = Duration::from_secs(100); - request.controls.no_store = true; - cache - .async_store(&request, json!({"v": 1}), now) - .await - .unwrap(); - assert_eq!(cache.lookup(&request, now).unwrap(), None); - request.controls.no_store = false; - request.controls.no_cache = true; - cache.store(&request, json!({"v": 2}), now).unwrap(); - assert_eq!(cache.async_lookup(&request, now).await.unwrap(), None); - request.controls.no_cache = false; - assert_eq!(cache.lookup(&request, now).unwrap(), Some(json!({"v": 2}))); - request.controls.default_on = false; - cache.store(&request, json!({"v": 3}), now).unwrap(); - assert_eq!(cache.lookup(&request, now).unwrap(), None); - request.controls.use_cache = true; - assert_eq!(cache.lookup(&request, now).unwrap(), Some(json!({"v": 2}))); - request.controls.supported_call_type = false; - assert_eq!(cache.lookup(&request, now).unwrap(), None); -} + let mut writer = request.clone(); + write.apply(&mut writer.controls); + let mut reader = request; + read.apply(&mut reader.controls); -#[tokio::test] -async fn redis_consumer_reads_python_sync_and_async_envelopes_and_writes_compatible_json() { - let connection = MockRedisConnection::new([ - MockCmd::new( - redis::cmd("GET").arg("tenant:key"), - Ok(br#"{'timestamp': 100.0, 'response': '{"ok": true, "text": "cached"}'}"#.to_vec()), - ), - MockCmd::new( - redis::cmd("GET").arg("tenant:key"), - Ok(br#"{"timestamp":100.0,"response":{"ok":true,"text":"cached"}}"#.to_vec()), - ), - MockCmd::new( - redis::cmd("SETEX") - .arg("tenant:key") - .arg(600) - .arg(br#"{"timestamp":100.0,"response":{"ok":true,"text":"cached"}}"#.as_slice()), - Ok("OK"), - ), - ]) - .assert_all_commands_consumed(); - let backend = RedisCache::with_connection(connection, None, ResponseCacheCodec) - .with_namespace(Some("tenant".into())); - let cache = ResponseCache::new(Arc::new(backend)); - let request = request(); - let expected = json!({"ok": true, "text": "cached"}); - assert_eq!( - cache.lookup(&request, Duration::from_secs(101)).unwrap(), - Some(expected.clone()) - ); - assert_eq!( - cache - .async_lookup(&request, Duration::from_secs(101)) + if asynchronous { + memory + .async_store(&writer, json!({"v": 1}), now) .await - .unwrap(), - Some(expected.clone()) + .unwrap(); + } else { + memory.store(&writer, json!({"v": 1}), now).unwrap(); + } + let found = if asynchronous { + memory.async_lookup(&reader, now).await.unwrap() + } else { + memory.lookup(&reader, now).unwrap() + }; + + assert_eq!( + found, + hit.then(|| json!({"v": 1})), + "{write:?} then {read:?}" + ); +} + +#[rstest] +#[case::python_sync_literal( + br#"{'timestamp': 100.0, 'response': '{"ok": true, "text": "cached"}'}"#.as_slice() +)] +#[case::python_async_json(br#"{"timestamp":100.0,"response":{"ok":true,"text":"cached"}}"#.as_slice())] +#[tokio::test] +async fn redis_consumer_reads_python_sync_and_async_envelopes( + request: ResponseCacheRequest, + #[case] stored: &[u8], + #[values(false, true)] asynchronous: bool, +) { + let cache = redis( + vec![MockCmd::new( + redis::cmd("GET").arg("tenant:key"), + Ok(stored.to_vec()), + )], + Some("tenant"), + ); + let now = Duration::from_secs(101); + let found = if asynchronous { + cache.async_lookup(&request, now).await.unwrap() + } else { + cache.lookup(&request, now).unwrap() + }; + assert_eq!(found, Some(json!({"ok": true, "text": "cached"}))); +} + +#[rstest] +#[case::object( + json!({"ok": true, "text": "cached"}), + br#"{"timestamp":100.0,"response":{"ok":true,"text":"cached"}}"#.as_slice() +)] +#[case::array(json!([1, 2]), br#"{"timestamp":100.0,"response":"[1,2]"}"#.as_slice())] +#[tokio::test] +async fn redis_consumer_writes_python_compatible_json( + request: ResponseCacheRequest, + #[case] response: Value, + #[case] wire: &[u8], +) { + let cache = redis( + vec![MockCmd::new( + redis::cmd("SETEX").arg("tenant:key").arg(600).arg(wire), + Ok("OK"), + )], + Some("tenant"), ); cache - .async_store(&request, expected, Duration::from_secs(100)) + .async_store(&request, response, Duration::from_secs(100)) .await .unwrap(); } +#[rstest] #[tokio::test] -async fn captured_service_keeps_the_selected_backend_for_background_writes() { - let original = memory(); +async fn invalid_entries_are_misses_and_disabled_reads_do_not_touch_redis( + mut request: ResponseCacheRequest, +) { + let cache = redis( + vec![MockCmd::new( + redis::cmd("GET").arg("tenant:key"), + Ok(b"invalid".to_vec()), + )], + None, + ); + request.controls.no_cache = true; + assert_eq!(cache.lookup(&request, Duration::ZERO).unwrap(), None); + request.controls.no_cache = false; + assert_eq!( + cache.async_lookup(&request, Duration::ZERO).await.unwrap(), + None + ); +} + +#[rstest] +#[tokio::test] +async fn captured_service_keeps_the_selected_backend_for_background_writes( + #[from(memory)] original: Memory, + #[from(memory)] replacement: Memory, + request: ResponseCacheRequest, +) { let captured = original.clone(); - let replacement = memory(); - let request = request(); let writer = tokio::spawn({ let request = request.clone(); async move { @@ -271,9 +429,13 @@ async fn captured_service_keeps_the_selected_backend_for_background_writes() { ); } -#[test] -fn generated_keys_preserve_namespace_and_explicit_keys() { - let cache = memory(); +#[rstest] +#[case::with_namespace(Some("tenant"))] +#[case::without_namespace(None)] +fn generated_keys_preserve_namespace_and_explicit_keys( + memory: Memory, + #[case] namespace: Option<&str>, +) { let key = CacheKeyInput { fields: vec![CacheKeyField { name: "model".into(), @@ -281,201 +443,125 @@ fn generated_keys_preserve_namespace_and_explicit_keys() { api_parameter: true, internal_parameter: false, }], - namespace: Some("tenant".into()), + namespace: namespace.map(str::to_owned), ..Default::default() }; let generated = ResponseCacheRequest::new(key.clone()); - let explicit = ResponseCacheRequest::new(CacheKeyInput { - preset: Some(litellm_cache_response::cache_key(&key)), - ..Default::default() - }); - cache + let explicit = keyed(&cache_key(&key)); + memory .store(&generated, json!({"value": 7}), Duration::from_secs(100)) .unwrap(); assert_eq!( - cache.lookup(&explicit, Duration::from_secs(100)).unwrap(), + memory.lookup(&explicit, Duration::from_secs(100)).unwrap(), Some(json!({"value":7})) ); } -#[test] -fn response_codec_accepts_python_literals_without_executing_code() { - let bytes = br#"{'timestamp': 100.0, 'response': {'text': 'hello \\ world', 'flag': True, 'empty': None, 'list': [1, 2.5]}}"#; - let entry = ResponseCacheCodec.decode(bytes).unwrap(); - assert_eq!( - entry.response, - json!({"text": "hello \\ world", "flag": true, "empty": null, "list": [1, 2.5]}) - ); - for bytes in [ - b"__import__('os').system('false')".as_slice(), - b"{'timestamp': 'invalid', 'response': {}}", - b"{'timestamp': 1e9999, 'response': {}}", - ] { - assert_eq!( - ResponseCacheCodec.decode(bytes).unwrap_err(), - Error::InvalidEntry - ); - } - let deep = format!("{}None{}", "[".repeat(1000), "]".repeat(1000)); - assert_eq!( - ResponseCacheCodec.decode(deep.as_bytes()).unwrap_err(), - Error::InvalidEntry - ); - assert_eq!( - ResponseCacheCodec - .encode(&CacheEntry { - timestamp: Some(f64::NAN), - response: json!({}) - }) - .unwrap_err(), - Error::InvalidEntry - ); -} - -#[tokio::test] -async fn invalid_entries_are_misses_and_disabled_reads_do_not_touch_redis() { - let connection = MockRedisConnection::new([MockCmd::new( - redis::cmd("GET").arg("tenant:key"), - Ok(b"invalid".to_vec()), - )]) - .assert_all_commands_consumed(); - let backend = RedisCache::with_connection(connection, None, ResponseCacheCodec); - let cache = ResponseCache::new(Arc::new(backend)); - let mut request = request(); - request.controls.no_cache = true; - assert_eq!(cache.lookup(&request, Duration::ZERO).unwrap(), None); - request.controls.no_cache = false; - assert_eq!( - cache.async_lookup(&request, Duration::ZERO).await.unwrap(), - None - ); -} - -#[test] -fn string_responses_round_trip_through_typed_and_wire_backends() { - let cache = ResponseCache::new(Arc::new(InMemoryCache::default())); +#[rstest] +#[case::text(json!("hello world"))] +#[case::numeric_text(json!("123"))] +#[case::null_text(json!("null"))] +#[case::array(json!([1, 2]))] +fn non_object_responses_round_trip_through_a_typed_backend( + memory: Memory, + request: ResponseCacheRequest, + #[case] response: Value, +) { let now = Duration::from_secs(100); - for response in [json!("hello world"), json!("123"), json!("null")] { - cache.store(&request(), response.clone(), now).unwrap(); - assert_eq!( - cache.lookup(&request(), now).unwrap(), - Some(response.clone()) - ); - - let wire = ResponseCacheCodec - .encode(&CacheEntry { - timestamp: Some(100.0), - response: response.clone(), - }) - .unwrap(); - assert_eq!(ResponseCacheCodec.decode(&wire).unwrap().response, response); - } + memory.store(&request, response.clone(), now).unwrap(); + assert_eq!(memory.lookup(&request, now).unwrap(), Some(response)); } -#[test] -fn non_object_responses_are_written_as_python_readable_serialized_strings() { - let wire = ResponseCacheCodec - .encode(&CacheEntry { - timestamp: Some(100.0), - response: json!([1, 2]), - }) - .unwrap(); - assert_eq!( - serde_json::from_slice::(&wire).unwrap(), - json!({"timestamp": 100.0, "response": "[1,2]"}) - ); - assert_eq!( - ResponseCacheCodec.decode(&wire).unwrap().response, - json!([1, 2]) - ); - assert_eq!( - ResponseCacheCodec.decode(br#"{"timestamp": 100.0, "response": "not serialized"}"#), - Err(Error::InvalidEntry) - ); -} - -#[test] -fn response_entries_preserve_the_existing_json_representation() { - let codec = ResponseCacheCodec; - let entry = CacheEntry { - timestamp: Some(123.0), - response: json!({"choices": [{"text": "cached"}]}), - }; - let bytes = codec.encode(&entry).unwrap(); - assert_eq!(bytes, serde_json::to_vec(&entry).unwrap()); - assert_eq!(codec.decode(&bytes).unwrap(), entry); -} - -#[test] -fn response_codec_preserves_values_without_timestamps() { - let codec = ResponseCacheCodec; - let raw = json!({"choices": [{"text": "legacy"}]}); - let entry = codec.decode(&serde_json::to_vec(&raw).unwrap()).unwrap(); - assert_eq!(entry.timestamp, None); - assert_eq!(entry.response, raw); - +#[rstest] +fn entries_without_timestamps_are_always_fresh(request: ResponseCacheRequest) { let backend = Arc::new(InMemoryCache::default()); - BaseCache::set_cache(backend.as_ref(), "tenant:key", entry, &Default::default()).unwrap(); + BaseCache::set_cache( + backend.as_ref(), + "tenant:key", + CacheEntry { + timestamp: None, + response: json!({"choices": [{"text": "legacy"}]}), + }, + &Default::default(), + ) + .unwrap(); let cache = ResponseCache::new(backend); + let mut request = request; + request.max_age = Some(Duration::from_secs(1)); assert_eq!( - cache.lookup(&request(), Duration::from_secs(100)).unwrap(), + cache.lookup(&request, Duration::from_secs(100)).unwrap(), Some(json!({"choices": [{"text": "legacy"}]})) ); } +#[rstest] #[tokio::test] -async fn batch_lookup_reports_partial_hits_and_batch_store_populates_misses() { - let cache = memory(); - let requests = ["hit", "miss", "disabled"].map(|key| { - ResponseCacheRequest::new(CacheKeyInput { - preset: Some(key.into()), - ..Default::default() - }) - }); - cache - .store(&requests[0], json!({"value": 1}), Duration::from_secs(100)) +async fn batch_lookup_reports_partial_hits_and_batch_store_populates_misses( + memory: Memory, + #[values(false, true)] asynchronous: bool, +) { + let now = Duration::from_secs(100); + let mut requests = ["hit", "miss", "disabled"].map(keyed).to_vec(); + memory + .store(&requests[0], json!({"value": 1}), now) .unwrap(); - let mut requests = requests.to_vec(); requests[2].controls.caching = Some(false); - let partial = cache - .async_lookup_batch(&requests, Duration::from_secs(100)) - .await - .unwrap(); + let partial = if asynchronous { + memory.async_lookup_batch(&requests, now).await.unwrap() + } else { + memory.lookup_batch(&requests, now).unwrap() + }; assert_eq!(partial.values, vec![Some(json!({"value": 1})), None, None]); assert_eq!(partial.missing_indices, vec![1, 2]); - cache + memory .async_store_batch( vec![ (requests[1].clone(), json!({"value": 2})), (requests[2].clone(), json!({"value": 3})), ], - Duration::from_secs(100), + now, ) .await .unwrap(); assert_eq!( - cache - .lookup(&requests[1], Duration::from_secs(100)) - .unwrap(), + memory.lookup(&requests[1], now).unwrap(), Some(json!({"value": 2})) ); requests[2].controls.caching = None; - assert_eq!( - cache - .lookup(&requests[2], Duration::from_secs(100)) - .unwrap(), - None - ); + assert_eq!(memory.lookup(&requests[2], now).unwrap(), None); } +#[rstest] #[tokio::test] -async fn deferred_entries_keep_the_time_they_were_produced() { - let cache = ResponseCache::new(Arc::new(InMemoryCache::default())); - let mut request = request(); +async fn batch_lookup_with_no_readable_request_skips_the_backend( + #[values(false, true)] asynchronous: bool, +) { + let cache = redis(Vec::new(), None); + let mut request = keyed("key"); + request.controls.no_cache = true; + let requests = [request.clone(), request]; + let partial = if asynchronous { + cache + .async_lookup_batch(&requests, Duration::ZERO) + .await + .unwrap() + } else { + cache.lookup_batch(&requests, Duration::ZERO).unwrap() + }; + assert_eq!(partial.values, vec![None, None]); + assert_eq!(partial.missing_indices, vec![0, 1]); +} + +#[rstest] +#[tokio::test] +async fn deferred_entries_keep_the_time_they_were_produced( + memory: Memory, + mut request: ResponseCacheRequest, +) { request.max_age = Some(Duration::from_secs(10)); - cache + memory .async_store_entries(vec![( request.clone(), json!({"answer": 7}), @@ -485,27 +571,29 @@ async fn deferred_entries_keep_the_time_they_were_produced() { .unwrap(); assert_eq!( - cache.lookup(&request, Duration::from_secs(110)).unwrap(), + memory.lookup(&request, Duration::from_secs(110)).unwrap(), Some(json!({"answer": 7})) ); assert_eq!( - cache.lookup(&request, Duration::from_secs(111)).unwrap(), + memory.lookup(&request, Duration::from_secs(111)).unwrap(), None ); } +#[rstest] #[tokio::test] -async fn write_buffer_flushes_at_its_size_and_keeps_each_produced_time() { - let cache = ResponseCache::new(Arc::new(InMemoryCache::default())); +async fn write_buffer_flushes_at_its_size_and_keeps_each_produced_time( + memory: Memory, + request: ResponseCacheRequest, +) { let buffer = WriteBuffer::new(2); - let mut first = request(); + let mut first = request; first.max_age = Some(Duration::from_secs(10)); - let mut second = request(); - second.key.preset = Some("tenant:other".into()); + let second = keyed("tenant:other"); buffer .async_store( - &cache, + memory.as_ref(), &first, json!({"answer": 7}), Duration::from_secs(100), @@ -513,13 +601,13 @@ async fn write_buffer_flushes_at_its_size_and_keeps_each_produced_time() { .await .unwrap(); assert_eq!( - cache.lookup(&first, Duration::from_secs(100)).unwrap(), + memory.lookup(&first, Duration::from_secs(100)).unwrap(), None ); buffer .async_store( - &cache, + memory.as_ref(), &second, json!({"answer": 8}), Duration::from_secs(200), @@ -527,37 +615,36 @@ async fn write_buffer_flushes_at_its_size_and_keeps_each_produced_time() { .await .unwrap(); assert_eq!( - cache.lookup(&first, Duration::from_secs(110)).unwrap(), + memory.lookup(&first, Duration::from_secs(110)).unwrap(), Some(json!({"answer": 7})) ); assert_eq!( - cache.lookup(&first, Duration::from_secs(111)).unwrap(), + memory.lookup(&first, Duration::from_secs(111)).unwrap(), None ); assert_eq!( - cache.lookup(&second, Duration::from_secs(200)).unwrap(), + memory.lookup(&second, Duration::from_secs(200)).unwrap(), Some(json!({"answer": 8})) ); } +#[rstest] #[tokio::test] -async fn write_buffer_clear_drops_pending_entries() { - let cache = ResponseCache::new(Arc::new(InMemoryCache::default())); +async fn write_buffer_clear_drops_pending_entries(memory: Memory, request: ResponseCacheRequest) { let buffer = WriteBuffer::new(2); - let mut other = request(); - other.key.preset = Some("tenant:other".into()); + let other = keyed("tenant:other"); let now = Duration::from_secs(100); buffer - .async_store(&cache, &request(), json!({"answer": 7}), now) + .async_store(memory.as_ref(), &request, json!({"answer": 7}), now) .await .unwrap(); buffer.clear().unwrap(); buffer - .async_store(&cache, &other, json!({"answer": 8}), now) + .async_store(memory.as_ref(), &other, json!({"answer": 8}), now) .await .unwrap(); - assert_eq!(cache.lookup(&request(), now).unwrap(), None); - assert_eq!(cache.lookup(&other, now).unwrap(), None); + assert_eq!(memory.lookup(&request, now).unwrap(), None); + assert_eq!(memory.lookup(&other, now).unwrap(), None); } diff --git a/litellm-rust/crates/cache-response/tests/support/mod.rs b/litellm-rust/crates/cache-response/tests/support/mod.rs new file mode 100644 index 00000000000..b992a937b70 --- /dev/null +++ b/litellm-rust/crates/cache-response/tests/support/mod.rs @@ -0,0 +1,40 @@ +use std::{sync::Arc, time::Duration}; + +use litellm_cache_memory::InMemoryCache; +use litellm_cache_redis::RedisCache; +use litellm_cache_response::{ + CacheEntry, CacheKeyInput, ResponseCache, ResponseCacheCodec, ResponseCacheRequest, +}; +use redis_test::{MockCmd, MockRedisConnection}; +use rstest::fixture; + +pub type MockedRedis = RedisCache; + +#[fixture] +pub fn memory() -> Arc>> { + Arc::new(ResponseCache::new(Arc::new(InMemoryCache::new( + Some(8), + Some(Duration::from_secs(600)), + )))) +} + +#[fixture] +pub fn request() -> ResponseCacheRequest { + keyed("tenant:key") +} + +pub fn keyed(key: &str) -> ResponseCacheRequest { + ResponseCacheRequest::new(CacheKeyInput { + preset: Some(key.into()), + ..Default::default() + }) +} + +/// A Redis response cache that must receive exactly `commands`, in order. +pub fn redis(commands: Vec, namespace: Option<&str>) -> ResponseCache { + let connection = MockRedisConnection::new(commands).assert_all_commands_consumed(); + ResponseCache::new(Arc::new( + RedisCache::with_connection(connection, None, ResponseCacheCodec) + .with_namespace(namespace.map(str::to_owned)), + )) +} diff --git a/litellm-rust/crates/cache-s3/Cargo.toml b/litellm-rust/crates/cache-s3/Cargo.toml index cdc17e732cb..c8150180e7c 100644 --- a/litellm-rust/crates/cache-s3/Cargo.toml +++ b/litellm-rust/crates/cache-s3/Cargo.toml @@ -10,11 +10,17 @@ litellm-cache.workspace = true litellm-auth-aws.workspace = true aws-sdk-s3 = { version = "1.146.1", default-features = false, features = ["rustls", "rt-tokio"] } aws-credential-types = "1.3.0" -aws-smithy-types = "1.6.0" +aws-smithy-runtime-api = { version = "1.16.2", features = ["client", "http-1x"] } +aws-smithy-types = { version = "1.6.0", features = ["http-body-1-x"] } aws-types = "1.6.0" +futures-util.workspace = true +http.workspace = true +reqwest.workspace = true tokio.workspace = true [dev-dependencies] +litellm-cache-testing.workspace = true +rstest.workspace = true wiremock = "0.6.5" serde_json.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/litellm-rust/crates/cache-s3/src/auth.rs b/litellm-rust/crates/cache-s3/src/auth.rs index b7ca722cea3..fdf71fc011b 100644 --- a/litellm-rust/crates/cache-s3/src/auth.rs +++ b/litellm-rust/crates/cache-s3/src/auth.rs @@ -5,22 +5,22 @@ use aws_credential_types::{ use litellm_auth_aws::{AwsAuthConfig, resolve_credentials}; #[derive(Clone)] -pub(crate) struct Credentials { +pub struct S3Credentials { config: AwsAuthConfig, env: fn(&str) -> Option, } -impl Credentials { - pub(crate) fn new(config: AwsAuthConfig) -> Self { +impl S3Credentials { + pub fn new(config: AwsAuthConfig) -> Self { Self::with_env(config, |name| std::env::var(name).ok()) } - pub(crate) fn with_env(config: AwsAuthConfig, env: fn(&str) -> Option) -> Self { + pub fn with_env(config: AwsAuthConfig, env: fn(&str) -> Option) -> Self { Self { config, env } } } -impl ProvideCredentials for Credentials { +impl ProvideCredentials for S3Credentials { fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a> where Self: 'a, @@ -45,57 +45,8 @@ impl ProvideCredentials for Credentials { } } -impl std::fmt::Debug for Credentials { +impl std::fmt::Debug for S3Credentials { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Credentials").finish_non_exhaustive() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn explicit_keys_ignore_an_ambient_session_token() { - let provider = Credentials::with_env( - AwsAuthConfig { - access_key_id: Some("key".to_string()), - secret_access_key: Some("secret".to_string()), - region_name: Some("us-east-1".to_string()), - ..Default::default() - }, - |name| (name == "AWS_SESSION_TOKEN").then(|| "ambient".to_string()), - ); - let credentials = provider.provide_credentials().await.unwrap(); - assert_eq!(credentials.access_key_id(), "key"); - assert_eq!(credentials.secret_access_key(), "secret"); - assert_eq!(credentials.session_token(), None); - } - - #[tokio::test] - async fn explicit_keys_keep_their_session_token() { - let provider = Credentials::new(AwsAuthConfig { - access_key_id: Some("key".to_string()), - secret_access_key: Some("secret".to_string()), - session_token: Some("t".to_string()), - region_name: Some("us-east-1".to_string()), - ..Default::default() - }); - let credentials = provider.provide_credentials().await.unwrap(); - assert_eq!(credentials.session_token(), Some("t")); - } - - #[tokio::test] - async fn environment_keys_resolve_with_their_session_token() { - let provider = Credentials::with_env(AwsAuthConfig::default(), |name| match name { - "AWS_ACCESS_KEY_ID" => Some("env-key".to_string()), - "AWS_SECRET_ACCESS_KEY" => Some("env-secret".to_string()), - "AWS_SESSION_TOKEN" => Some("env-token".to_string()), - _ => None, - }); - let credentials = provider.provide_credentials().await.unwrap(); - assert_eq!(credentials.access_key_id(), "env-key"); - assert_eq!(credentials.secret_access_key(), "env-secret"); - assert_eq!(credentials.session_token(), Some("env-token")); + f.debug_struct("S3Credentials").finish_non_exhaustive() } } diff --git a/litellm-rust/crates/cache-s3/src/cache.rs b/litellm-rust/crates/cache-s3/src/cache.rs index 9c791f42c3b..91cd5e8ef54 100644 --- a/litellm-rust/crates/cache-s3/src/cache.rs +++ b/litellm-rust/crates/cache-s3/src/cache.rs @@ -10,13 +10,14 @@ use aws_sdk_s3::{ primitives::ByteStream, }; use aws_smithy_types::{DateTime, date_time::Format}; +use futures_util::future::try_join_all; use litellm_auth_aws::AwsAuthConfig; use litellm_cache::{ - BaseCache, BatchCache, CacheCodec, CacheConnectionResult, Error, ExactCacheContext, FlushCache, + BaseCache, BatchCache, CacheCodec, DisconnectCache, Error, ExactCacheContext, FlushCache, }; use tokio::runtime::Handle; -use crate::auth::Credentials; +use crate::{auth::S3Credentials, transport::ReqwestHttpClient}; pub struct S3Endpoint { pub url: String, @@ -41,12 +42,13 @@ pub struct S3Cache { } impl S3Cache { - pub fn new(config: S3CacheConfig, codec: C, runtime: Handle) -> Self { + pub fn new(config: S3CacheConfig, http: reqwest::Client, codec: C, runtime: Handle) -> Self { let endpoint_url: Option = config.endpoint.map(|endpoint| endpoint.url); let base = aws_sdk_s3::Config::builder() .behavior_version(BehaviorVersion::latest()) .region(Region::new(config.region.clone())) - .credentials_provider(Credentials::new(config.auth)) + .http_client(ReqwestHttpClient(http)) + .credentials_provider(S3Credentials::new(config.auth)) .request_checksum_calculation(RequestChecksumCalculation::WhenRequired) .response_checksum_validation(ResponseChecksumValidation::WhenRequired); let builder = match &endpoint_url { @@ -202,13 +204,26 @@ impl BaseCache for S3Cache { self.get(key).await } + async fn async_set_cache_pipeline( + &self, + entries: Vec<(String, Self::Value)>, + context: Self::Context, + ) -> Result<(), Error> { + let context = &context; + try_join_all( + entries + .into_iter() + .map(|(key, value)| async move { self.put(&key, value, context).await }), + ) + .await + .map(drop) + } +} + +impl DisconnectCache for S3Cache { async fn disconnect(&self) -> Result<(), Error> { Ok(()) } - - async fn test_connection(&self) -> Result { - Err(Error::UnsupportedOperation) - } } impl BatchCache for S3Cache {} diff --git a/litellm-rust/crates/cache-s3/src/lib.rs b/litellm-rust/crates/cache-s3/src/lib.rs index f6126dfa908..9be0210aad3 100644 --- a/litellm-rust/crates/cache-s3/src/lib.rs +++ b/litellm-rust/crates/cache-s3/src/lib.rs @@ -1,4 +1,6 @@ mod auth; mod cache; +mod transport; +pub use auth::S3Credentials; pub use cache::{S3Cache, S3CacheConfig, S3Endpoint}; diff --git a/litellm-rust/crates/cache-s3/src/transport.rs b/litellm-rust/crates/cache-s3/src/transport.rs new file mode 100644 index 00000000000..3e5ce578c31 --- /dev/null +++ b/litellm-rust/crates/cache-s3/src/transport.rs @@ -0,0 +1,49 @@ +use aws_smithy_runtime_api::client::{ + http::{ + HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector, + }, + orchestrator::HttpRequest, + result::ConnectorError, + runtime_components::RuntimeComponents, +}; +use aws_smithy_types::body::SdkBody; + +#[derive(Clone, Debug)] +pub(crate) struct ReqwestHttpClient(pub(crate) reqwest::Client); + +impl HttpClient for ReqwestHttpClient { + fn http_connector( + &self, + _: &HttpConnectorSettings, + _: &RuntimeComponents, + ) -> SharedHttpConnector { + SharedHttpConnector::new(self.clone()) + } +} + +impl HttpConnector for ReqwestHttpClient { + fn call(&self, request: HttpRequest) -> HttpConnectorFuture { + let client = self.0.clone(); + HttpConnectorFuture::new(async move { + let request = request + .try_into_http1x() + .map_err(|error| ConnectorError::other(error.into(), None))? + .map(reqwest::Body::wrap); + let request = reqwest::Request::try_from(request) + .map_err(|error| ConnectorError::other(error.into(), None))?; + let response = client.execute(request).await.map_err(|error| { + if error.is_timeout() { + ConnectorError::timeout(error.into()) + } else { + ConnectorError::io(error.into()) + } + })?; + let response = http::Response::from(response).map(SdkBody::from_body_1_x); + response + .try_into() + .map_err(|error: aws_smithy_runtime_api::http::HttpError| { + ConnectorError::other(error.into(), None) + }) + }) + } +} diff --git a/litellm-rust/crates/cache-s3/tests/auth.rs b/litellm-rust/crates/cache-s3/tests/auth.rs new file mode 100644 index 00000000000..59c5aa2e091 --- /dev/null +++ b/litellm-rust/crates/cache-s3/tests/auth.rs @@ -0,0 +1,57 @@ +use aws_credential_types::provider::ProvideCredentials; +use litellm_auth_aws::AwsAuthConfig; +use litellm_cache_s3::S3Credentials; +use rstest::rstest; + +fn explicit(session_token: Option<&str>) -> AwsAuthConfig { + AwsAuthConfig { + access_key_id: Some("key".to_string()), + secret_access_key: Some("secret".to_string()), + session_token: session_token.map(str::to_string), + region_name: Some("us-east-1".to_string()), + ..Default::default() + } +} + +fn ambient_token(name: &str) -> Option { + (name == "AWS_SESSION_TOKEN").then(|| "ambient".to_string()) +} + +fn environment_keys(name: &str) -> Option { + match name { + "AWS_ACCESS_KEY_ID" => Some("env-key".to_string()), + "AWS_SECRET_ACCESS_KEY" => Some("env-secret".to_string()), + "AWS_SESSION_TOKEN" => Some("env-token".to_string()), + _ => None, + } +} + +#[rstest] +#[case::explicit_keys_ignore_an_ambient_session_token( + explicit(None), ambient_token, ("key", "secret", None) +)] +#[case::explicit_keys_keep_their_session_token( + explicit(Some("t")), ambient_token, ("key", "secret", Some("t")) +)] +#[case::environment_keys_resolve_with_their_session_token( + AwsAuthConfig::default(), environment_keys, ("env-key", "env-secret", Some("env-token")) +)] +#[tokio::test] +async fn credentials_resolve( + #[case] config: AwsAuthConfig, + #[case] env: fn(&str) -> Option, + #[case] expected: (&str, &str, Option<&str>), +) { + let credentials = S3Credentials::with_env(config, env) + .provide_credentials() + .await + .unwrap(); + assert_eq!( + ( + credentials.access_key_id(), + credentials.secret_access_key(), + credentials.session_token(), + ), + expected + ); +} diff --git a/litellm-rust/crates/cache-s3/tests/cache.rs b/litellm-rust/crates/cache-s3/tests/cache.rs index 9a71656286b..53a51f8a289 100644 --- a/litellm-rust/crates/cache-s3/tests/cache.rs +++ b/litellm-rust/crates/cache-s3/tests/cache.rs @@ -1,41 +1,23 @@ +mod support; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use litellm_auth_aws::AwsAuthConfig; +use aws_smithy_types::{DateTime, date_time::Format}; use litellm_cache::{ - BaseCache, BatchCache, BatchEntry, Error, ExactCacheContext, FlushCache, JsonCodec, + BaseCache, BatchCache, BatchEntry, DisconnectCache, Error, ExactCacheContext, FlushCache, }; -use litellm_cache_s3::{S3Cache, S3CacheConfig, S3Endpoint}; +use litellm_cache_s3::S3CacheConfig; +use rstest::{fixture, rstest}; use serde_json::{Value, json}; -use tokio::runtime::Handle; +use support::FakeBucket; use wiremock::{ Mock, MockServer, ResponseTemplate, + http::HeaderMap, matchers::{method, path}, }; -fn config(endpoint: String) -> S3CacheConfig { - S3CacheConfig { - bucket: "cache-bucket".to_string(), - key_prefix: "team/".to_string(), - region: "us-east-1".to_string(), - endpoint: Some(S3Endpoint { url: endpoint }), - auth: AwsAuthConfig { - access_key_id: Some("key".to_string()), - secret_access_key: Some("secret".to_string()), - region_name: Some("us-east-1".to_string()), - ..Default::default() - }, - } -} - -fn cache(endpoint: &str) -> S3Cache> { - S3Cache::new( - config(endpoint.to_string()), - JsonCodec::::new(), - Handle::current(), - ) -} - -async fn mock_server() -> MockServer { +#[fixture] +async fn server() -> MockServer { let server = MockServer::start().await; Mock::given(method("PUT")) .respond_with(ResponseTemplate::new(200).insert_header("etag", "\"etag\"")) @@ -44,23 +26,25 @@ async fn mock_server() -> MockServer { server } -fn http_date_from(headers: &wiremock::http::HeaderMap, name: &str) -> Option { - use aws_smithy_types::{DateTime, date_time::Format}; +fn http_date_from(headers: &HeaderMap, name: &str) -> Option { headers .get(name) .and_then(|value| DateTime::from_str(value.to_str().ok()?, Format::HttpDate).ok()) .map(|date| UNIX_EPOCH + Duration::new(date.secs() as u64, date.subsec_nanos())) } +fn ttl(seconds: u64) -> ExactCacheContext { + ExactCacheContext { + ttl: Some(Duration::from_secs(seconds)), + } +} + +#[rstest] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn set_writes_python_metadata_with_and_without_ttl() { - let server = mock_server().await; - let cache = cache(&server.uri()); - let context = ExactCacheContext { - ttl: Some(Duration::from_secs(90)), - }; +async fn set_writes_python_metadata_with_and_without_ttl(#[future(awt)] server: MockServer) { + let cache = support::cache(&server.uri()); cache - .set_cache("alpha:beta", json!({"answer": 1}), &context) + .set_cache("alpha:beta", json!({"answer": 1}), &ttl(90)) .unwrap(); cache .set_cache("plain", json!({"answer": 2}), &ExactCacheContext::default()) @@ -110,61 +94,84 @@ async fn set_writes_python_metadata_with_and_without_ttl() { ); } +#[rstest] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_hit_miss_expired_and_invalid_entries() { - let server = mock_server().await; - Mock::given(method("GET")) - .and(path("/cache-bucket/team/hit")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"answer": 3}))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/cache-bucket/team/missing")) - .respond_with( - ResponseTemplate::new(404).set_body_string("NoSuchKey"), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/cache-bucket/team/denied")) - .respond_with( - ResponseTemplate::new(403).set_body_string("AccessDenied"), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/cache-bucket/team/expired")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("expires", "Thu, 01 Jan 1970 00:00:00 GMT") - .set_body_json(json!({"answer": 4})), - ) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/cache-bucket/team/malformed")) - .respond_with(ResponseTemplate::new(200).set_body_string("not a cache entry")) - .mount(&server) - .await; - let cache = cache(&server.uri()); - let context = ExactCacheContext::default(); +async fn async_set_signs_with_the_configured_keys(#[future(awt)] server: MockServer) { + support::cache(&server.uri()) + .async_set_cache("key", json!({"answer": 1}), ttl(3600)) + .await + .unwrap(); - assert_eq!( - cache.get_cache("hit", &context).unwrap(), - Some(json!({"answer": 3})) + let requests = server.received_requests().await.unwrap(); + let request = &requests[0]; + assert_eq!(request.url.path(), "/cache-bucket/team/key"); + assert!( + request.headers["authorization"] + .to_str() + .unwrap() + .contains("Credential=key/") ); - assert_eq!(cache.get_cache("missing", &context).unwrap(), None); - assert_eq!(cache.get_cache("denied", &context).unwrap(), None); - assert_eq!(cache.get_cache("expired", &context).unwrap(), None); + assert!(request.headers.get("x-amz-security-token").is_none()); assert_eq!( - cache.get_cache("malformed", &context), - Err(Error::InvalidEntry) + request.headers["cache-control"].to_str().unwrap(), + "immutable, max-age=3600, s-maxage=3600" ); } +#[rstest] +#[case::hit("hit", ResponseTemplate::new(200).set_body_json(json!({"answer": 3})), Ok(Some(json!({"answer": 3}))))] +#[case::no_such_key( + "missing", + ResponseTemplate::new(404).set_body_string("NoSuchKey"), + Ok(None) +)] +#[case::access_denied( + "denied", + ResponseTemplate::new(403).set_body_string("AccessDenied"), + Ok(None) +)] +#[case::expired( + "expired", + ResponseTemplate::new(200) + .insert_header("expires", "Thu, 01 Jan 1970 00:00:00 GMT") + .set_body_json(json!({"answer": 4})), + Ok(None) +)] +#[case::not_yet_expired( + "fresh", + ResponseTemplate::new(200) + .insert_header("expires", "Fri, 01 Jan 2100 00:00:00 GMT") + .set_body_json(json!({"answer": 5})), + Ok(Some(json!({"answer": 5}))) +)] +#[case::malformed( + "malformed", + ResponseTemplate::new(200).set_body_string("not a cache entry"), + Err(Error::InvalidEntry) +)] +#[case::server_error("broken", ResponseTemplate::new(500), Err(Error::Unavailable))] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn batch_get_preserves_order_with_hits_misses_and_invalid() { - let server = mock_server().await; +async fn get_maps_s3_responses( + #[future(awt)] server: MockServer, + #[case] key: &str, + #[case] response: ResponseTemplate, + #[case] expected: Result, Error>, +) { + Mock::given(method("GET")) + .and(path(format!("/cache-bucket/team/{key}"))) + .respond_with(response) + .mount(&server) + .await; + let cache = support::cache(&server.uri()); + let context = ExactCacheContext::default(); + + assert_eq!(cache.get_cache(key, &context), expected); + assert_eq!(cache.async_get_cache(key, &context).await, expected); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn batch_get_preserves_order_with_hits_misses_and_invalid(#[future(awt)] server: MockServer) { for (key, status, body) in [ ("first", 200, "{\"answer\": 1}"), ("invalid", 200, "garbage"), @@ -180,91 +187,117 @@ async fn batch_get_preserves_order_with_hits_misses_and_invalid() { .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; - let cache = cache(&server.uri()); + let cache = support::cache(&server.uri()); let context = ExactCacheContext::default(); let keys = vec![ "first".to_string(), "miss".to_string(), "invalid".to_string(), ]; + let expected = vec![ + BatchEntry::Hit(json!({"answer": 1})), + BatchEntry::Miss, + BatchEntry::Invalid, + ]; - let entries = cache.batch_get_cache(&keys, &context).unwrap(); - + assert_eq!(cache.batch_get_cache(&keys, &context).unwrap(), expected); assert_eq!( - entries, - vec![ - BatchEntry::Hit(json!({"answer": 1})), - BatchEntry::Miss, - BatchEntry::Invalid, - ] + cache.async_batch_get_cache(keys, context).await.unwrap(), + expected ); } +#[rstest] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unsupported_and_noop_capabilities_match_python() { - let server = mock_server().await; - let cache = cache(&server.uri()); +async fn pipeline_writes_every_entry_with_the_shared_ttl() { + let server = FakeBucket::serve().await; + let cache = support::cache(&server.uri()); + cache + .async_set_cache_pipeline( + vec![ + ("one".into(), json!({"n": 1})), + ("two".into(), json!({"n": 2})), + ], + ttl(30), + ) + .await + .unwrap(); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|request| { + request.headers["cache-control"].to_str().unwrap() == "immutable, max-age=30, s-maxage=30" + })); assert_eq!( - cache.test_connection().await, - Err(Error::UnsupportedOperation) + cache + .get_cache("two", &ExactCacheContext::default()) + .unwrap(), + Some(json!({"n": 2})) ); +} + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn flush_and_disconnect_are_noops_like_python(#[future(awt)] server: MockServer) { + let cache = support::cache(&server.uri()); + cache.flush_cache().unwrap(); + cache.async_flush_cache().await.unwrap(); cache.disconnect().await.unwrap(); - assert_eq!(cache.get_ttl(&ExactCacheContext::default()), None); - assert_eq!( - cache.get_ttl(&ExactCacheContext { - ttl: Some(Duration::from_secs(45)), - }), - Some(Duration::from_secs(45)) - ); assert!(server.received_requests().await.unwrap().is_empty()); } -#[test] -fn key_conversion_prefixes_and_splits_colons() { +#[rstest] +#[case::without_ttl(ExactCacheContext::default(), None)] +#[case::with_ttl(ttl(45), Some(Duration::from_secs(45)))] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_ttl_reports_the_request_ttl( + #[case] context: ExactCacheContext, + #[case] expected: Option, +) { + assert_eq!( + support::cache("http://localhost").get_ttl(&context), + expected + ); +} + +#[rstest] +#[case::prefixed("team/", "a:b:c", "team/a/b/c")] +#[case::prefixed_plain("team/", "plain", "team/plain")] +#[case::unprefixed("", "a:b", "a/b")] +fn key_conversion_prefixes_and_splits_colons( + #[case] key_prefix: &str, + #[case] key: &str, + #[case] expected: &str, +) { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() .build() .unwrap(); - let _guard = runtime.enter(); - let cache = S3Cache::new( + let cache = support::cache_with( S3CacheConfig { - key_prefix: "team/".to_string(), - ..config("http://localhost".to_string()) + key_prefix: key_prefix.to_string(), + ..support::config("http://localhost") }, - JsonCodec::::new(), runtime.handle().clone(), ); assert_eq!(cache.bucket(), "cache-bucket"); - assert_eq!(cache.key_prefix(), "team/"); - assert_eq!(cache.to_s3_key("a:b:c"), "team/a/b/c"); - assert_eq!(cache.to_s3_key("plain"), "team/plain"); - - let unprefixed = S3Cache::new( - S3CacheConfig { - key_prefix: String::new(), - ..config("http://localhost".to_string()) - }, - JsonCodec::::new(), - runtime.handle().clone(), - ); - assert_eq!(unprefixed.to_s3_key("a:b"), "a/b"); + assert_eq!(cache.key_prefix(), key_prefix); + assert_eq!(cache.region(), "us-east-1"); + assert_eq!(cache.endpoint(), Some("http://localhost")); + assert_eq!(cache.to_s3_key(key), expected); } +#[rstest] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sync_methods_block_inside_and_outside_the_runtime() { - let server = mock_server().await; - Mock::given(method("GET")) - .and(path("/cache-bucket/team/key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"answer": 9}))) - .mount(&server) - .await; +async fn sync_methods_block_outside_the_runtime() { + let server = FakeBucket::serve().await; let uri = server.uri(); - let cache = tokio::task::spawn_blocking(move || { - let cache = cache(&uri); + let handle = tokio::runtime::Handle::current(); + let cached = tokio::task::spawn_blocking(move || { + let cache = support::cache_with(support::config(&uri), handle); let context = ExactCacheContext::default(); cache .set_cache("key", json!({"answer": 9}), &context) @@ -274,5 +307,5 @@ async fn sync_methods_block_inside_and_outside_the_runtime() { .await .unwrap(); - assert_eq!(cache, Some(json!({"answer": 9}))); + assert_eq!(cached, Some(json!({"answer": 9}))); } diff --git a/litellm-rust/crates/cache-s3/tests/contract.rs b/litellm-rust/crates/cache-s3/tests/contract.rs new file mode 100644 index 00000000000..9855e2868ff --- /dev/null +++ b/litellm-rust/crates/cache-s3/tests/contract.rs @@ -0,0 +1,65 @@ +mod support; + +use litellm_cache::ExactCacheContext; +use litellm_cache_testing as contract; +use rstest::{fixture, rstest}; +use serde_json::json; +use support::{FakeBucket, JsonS3Cache}; +use wiremock::MockServer; + +struct S3 { + cache: JsonS3Cache, + _server: MockServer, +} + +#[fixture] +async fn s3() -> S3 { + let server = FakeBucket::serve().await; + S3 { + cache: support::cache(&server.uri()), + _server: server, + } +} + +#[fixture] +fn context() -> ExactCacheContext { + ExactCacheContext::default() +} + +const PREFIX: &str = "contract:"; + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hit_and_miss(#[future(awt)] s3: S3, context: ExactCacheContext) { + contract::hit_and_miss(&s3.cache, context, PREFIX, json!({"answer": 42})).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sync_async_equivalence(#[future(awt)] s3: S3, context: ExactCacheContext) { + contract::sync_async_equivalence(&s3.cache, context, PREFIX, json!("first"), json!([2])).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn overwrite_replaces(#[future(awt)] s3: S3, context: ExactCacheContext) { + contract::overwrite_replaces(&s3.cache, context, PREFIX, json!(1), json!({"b": 2})).await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipeline_writes_every_entry(#[future(awt)] s3: S3, context: ExactCacheContext) { + contract::pipeline_writes_every_entry( + &s3.cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; +} + +#[rstest] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn batch_preserves_order(#[future(awt)] s3: S3, context: ExactCacheContext) { + contract::batch_preserves_order(&s3.cache, context, PREFIX, json!("first"), json!(2)).await; +} diff --git a/litellm-rust/crates/cache-s3/tests/support/mod.rs b/litellm-rust/crates/cache-s3/tests/support/mod.rs new file mode 100644 index 00000000000..046b042c66a --- /dev/null +++ b/litellm-rust/crates/cache-s3/tests/support/mod.rs @@ -0,0 +1,77 @@ +#![allow(dead_code)] + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use litellm_auth_aws::AwsAuthConfig; +use litellm_cache::JsonCodec; +use litellm_cache_s3::{S3Cache, S3CacheConfig, S3Endpoint}; +use serde_json::Value; +use tokio::runtime::Handle; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate, http::Method, matchers::any}; + +pub type JsonS3Cache = S3Cache>; + +pub fn config(endpoint: &str) -> S3CacheConfig { + S3CacheConfig { + bucket: "cache-bucket".to_string(), + key_prefix: "team/".to_string(), + region: "us-east-1".to_string(), + endpoint: Some(S3Endpoint { + url: endpoint.to_string(), + }), + auth: AwsAuthConfig { + access_key_id: Some("key".to_string()), + secret_access_key: Some("secret".to_string()), + region_name: Some("us-east-1".to_string()), + ..Default::default() + }, + } +} + +pub fn cache_with(config: S3CacheConfig, runtime: Handle) -> JsonS3Cache { + S3Cache::new(config, reqwest::Client::new(), JsonCodec::new(), runtime) +} + +pub fn cache(endpoint: &str) -> JsonS3Cache { + cache_with(config(endpoint), Handle::current()) +} + +/// An in-memory bucket: PUT stores the body under the request path, GET serves it or answers +/// `NoSuchKey`. +#[derive(Clone, Default)] +pub struct FakeBucket { + objects: Arc>>>, +} + +impl FakeBucket { + pub async fn serve() -> MockServer { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(Self::default()) + .mount(&server) + .await; + server + } +} + +impl Respond for FakeBucket { + fn respond(&self, request: &Request) -> ResponseTemplate { + let path = request.url.path().to_string(); + let mut objects = self.objects.lock().unwrap(); + match request.method { + Method::PUT => { + objects.insert(path, request.body.clone()); + ResponseTemplate::new(200).insert_header("etag", "\"etag\"") + } + Method::GET => match objects.get(&path) { + Some(body) => ResponseTemplate::new(200).set_body_bytes(body.clone()), + None => ResponseTemplate::new(404) + .set_body_string("NoSuchKey"), + }, + _ => ResponseTemplate::new(405), + } + } +} diff --git a/litellm-rust/crates/cache-testing/Cargo.toml b/litellm-rust/crates/cache-testing/Cargo.toml new file mode 100644 index 00000000000..674472df50d --- /dev/null +++ b/litellm-rust/crates/cache-testing/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "litellm-cache-testing" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +litellm-cache.workspace = true diff --git a/litellm-rust/crates/cache-testing/src/lib.rs b/litellm-rust/crates/cache-testing/src/lib.rs new file mode 100644 index 00000000000..c227c1e12dd --- /dev/null +++ b/litellm-rust/crates/cache-testing/src/lib.rs @@ -0,0 +1,211 @@ +//! Backend-neutral contract checks every cache backend runs from its own `rstest` suite. +//! +//! Each check takes the cache under test, the context to call it with, a key `prefix` that +//! keeps runs apart on shared servers, and distinct sample values. A check panics with the +//! violated invariant, so a backend test is one `#[rstest]` case per contract. + +use std::fmt::Debug; + +use litellm_cache::{BaseCache, BatchCache, BatchEntry, CounterCache, DeleteCache, FlushCache}; + +fn key(prefix: &str, name: &str) -> String { + format!("{prefix}{name}") +} + +/// A missing key reads as `None`, and a written key reads back through sync and async gets. +pub async fn hit_and_miss(cache: &B, context: B::Context, prefix: &str, value: B::Value) +where + B: BaseCache, + B::Value: Debug + PartialEq, +{ + let key = key(prefix, "hit-and-miss"); + assert_eq!( + cache.get_cache(&key, &context).unwrap(), + None, + "unwritten key must miss" + ); + assert_eq!( + cache.async_get_cache(&key, &context).await.unwrap(), + None, + "unwritten key must miss asynchronously" + ); + cache.set_cache(&key, value.clone(), &context).unwrap(); + assert_eq!( + cache.get_cache(&key, &context).unwrap(), + Some(value.clone()) + ); + assert_eq!( + cache.async_get_cache(&key, &context).await.unwrap(), + Some(value) + ); +} + +/// Sync and async writes land in the same store: each is visible to the other read path. +pub async fn sync_async_equivalence( + cache: &B, + context: B::Context, + prefix: &str, + first: B::Value, + second: B::Value, +) where + B: BaseCache, + B::Value: Debug + PartialEq, +{ + let async_written = key(prefix, "async-written"); + let sync_written = key(prefix, "sync-written"); + cache + .async_set_cache(&async_written, first.clone(), context.clone()) + .await + .unwrap(); + assert_eq!( + cache.get_cache(&async_written, &context).unwrap(), + Some(first) + ); + cache + .set_cache(&sync_written, second.clone(), &context) + .unwrap(); + assert_eq!( + cache + .async_get_cache(&sync_written, &context) + .await + .unwrap(), + Some(second) + ); +} + +/// A second write to a key replaces the first. +pub async fn overwrite_replaces( + cache: &B, + context: B::Context, + prefix: &str, + first: B::Value, + second: B::Value, +) where + B: BaseCache, + B::Value: Debug + PartialEq, +{ + let key = key(prefix, "overwrite"); + cache.set_cache(&key, first, &context).unwrap(); + cache.set_cache(&key, second.clone(), &context).unwrap(); + assert_eq!(cache.get_cache(&key, &context).unwrap(), Some(second)); +} + +/// `async_set_cache_pipeline` writes every entry, and an empty pipeline succeeds. +pub async fn pipeline_writes_every_entry( + cache: &B, + context: B::Context, + prefix: &str, + values: Vec, +) where + B: BaseCache, + B::Value: Debug + PartialEq, +{ + cache + .async_set_cache_pipeline(Vec::new(), context.clone()) + .await + .unwrap(); + let entries = values + .iter() + .enumerate() + .map(|(index, value)| (key(prefix, &format!("pipeline-{index}")), value.clone())) + .collect::>(); + cache + .async_set_cache_pipeline(entries.clone(), context.clone()) + .await + .unwrap(); + for (key, value) in entries { + assert_eq!( + cache.get_cache(&key, &context).unwrap(), + Some(value), + "{key}" + ); + } +} + +/// Batch reads answer in request order, with a `Miss` in place of each absent key. +pub async fn batch_preserves_order( + cache: &B, + context: B::Context, + prefix: &str, + first: B::Value, + second: B::Value, +) where + B: BatchCache, + B::Value: Debug + PartialEq, +{ + let keys = vec![ + key(prefix, "batch-first"), + key(prefix, "batch-missing"), + key(prefix, "batch-second"), + ]; + cache.set_cache(&keys[0], first.clone(), &context).unwrap(); + cache.set_cache(&keys[2], second.clone(), &context).unwrap(); + let expected = vec![ + BatchEntry::Hit(first), + BatchEntry::Miss, + BatchEntry::Hit(second), + ]; + assert_eq!(cache.batch_get_cache(&keys, &context).unwrap(), expected); + assert_eq!( + cache.async_batch_get_cache(keys, context).await.unwrap(), + expected + ); +} + +/// Sync and async deletes remove only the named key, and deleting a missing key succeeds. +pub async fn delete_removes_key(cache: &B, context: B::Context, prefix: &str, value: B::Value) +where + B: DeleteCache, + B::Value: Debug + PartialEq, +{ + let sync_deleted = key(prefix, "delete-sync"); + let async_deleted = key(prefix, "delete-async"); + let kept = key(prefix, "delete-kept"); + for key in [&sync_deleted, &async_deleted, &kept] { + cache.set_cache(key, value.clone(), &context).unwrap(); + } + cache.delete_cache(&sync_deleted).unwrap(); + cache.async_delete_cache(&async_deleted).await.unwrap(); + cache + .delete_cache(&key(prefix, "delete-never-written")) + .unwrap(); + assert_eq!(cache.get_cache(&sync_deleted, &context).unwrap(), None); + assert_eq!(cache.get_cache(&async_deleted, &context).unwrap(), None); + assert_eq!(cache.get_cache(&kept, &context).unwrap(), Some(value)); +} + +/// `flush_cache` and `async_flush_cache` each leave the cache empty. +pub async fn flush_clears(cache: &B, context: B::Context, prefix: &str, value: B::Value) +where + B: FlushCache, + B::Value: Debug + PartialEq, +{ + let key = key(prefix, "flush"); + cache.set_cache(&key, value.clone(), &context).unwrap(); + cache.flush_cache().unwrap(); + assert_eq!(cache.get_cache(&key, &context).unwrap(), None); + cache.set_cache(&key, value, &context).unwrap(); + cache.async_flush_cache().await.unwrap(); + assert_eq!(cache.get_cache(&key, &context).unwrap(), None); +} + +/// Sync and async increments accumulate on one counter, starting from zero. Whole-number +/// steps, since Python's disk cache restarts any counter whose stored value is not an `int`. +pub async fn counter_accumulates(cache: &B, context: B::Context, prefix: &str) +where + B: CounterCache, +{ + let key = key(prefix, "counter"); + assert_eq!( + cache.increment_cache(&key, 1.0, context.clone()).unwrap(), + 1.0 + ); + assert_eq!( + cache + .async_increment(&key, 2.0, context.clone(), false) + .await + .unwrap(), + 3.0 + ); + assert_eq!(cache.increment_cache(&key, -1.0, context).unwrap(), 2.0); +} diff --git a/litellm-rust/crates/cache-valkey-semantic/Cargo.toml b/litellm-rust/crates/cache-valkey-semantic/Cargo.toml index f98bb5a5fa8..da9bb60c853 100644 --- a/litellm-rust/crates/cache-valkey-semantic/Cargo.toml +++ b/litellm-rust/crates/cache-valkey-semantic/Cargo.toml @@ -8,13 +8,13 @@ repository.workspace = true [dependencies] litellm-cache.workspace = true litellm-cache-redis.workspace = true -litellm-cache-response.workspace = true redis = { version = "1.7.0", features = ["tls-rustls"] } -serde_json.workspace = true sha2.workspace = true -tokio.workspace = true uuid = { version = "1", features = ["v4"] } [dev-dependencies] +litellm-cache-testing.workspace = true redis-test = "1.0.4" rstest.workspace = true +serde_json.workspace = true +tokio.workspace = true diff --git a/litellm-rust/crates/cache-valkey-semantic/src/cache.rs b/litellm-rust/crates/cache-valkey-semantic/src/cache.rs new file mode 100644 index 00000000000..947c0edc479 --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/src/cache.rs @@ -0,0 +1,245 @@ +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use litellm_cache::{ + BaseCache, CacheCodec, Error, SemanticCacheContext, + semantic::{Embedder, SemanticCache, SemanticLookup, prompt_from_context}, +}; +use litellm_cache_redis::{RedisTopology, connection::Connections}; + +use crate::{ + ValkeySemanticConfig, + index::IndexState, + search::{embedding_bytes, scope_tag, search_document, write_document}, +}; + +/// `ValkeySemanticCache`: a semantic cache on valkey-search's TAG + VECTOR index. Values go +/// through the injected codec, so the response layer decides what a cached entry is. +pub struct ValkeySemanticCache { + connections: Arc>, + embedder: E, + codec: S, + config: ValkeySemanticConfig, + index_dimension: Arc>>, +} + +impl ValkeySemanticCache { + pub fn new( + url: &str, + embedder: E, + codec: S, + config: ValkeySemanticConfig, + ) -> Result { + Ok(Self { + connections: Arc::new(Connections::open(url, &RedisTopology::Standalone)?), + embedder, + codec, + config, + index_dimension: Arc::new(Mutex::new(None)), + }) + } +} + +impl ValkeySemanticCache +where + E: Embedder, + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + pub fn with_connection( + connection: C, + embedder: E, + codec: S, + config: ValkeySemanticConfig, + ) -> Self { + Self { + connections: Arc::new(Connections::fixed(connection)), + embedder, + codec, + config, + index_dimension: Arc::new(Mutex::new(None)), + } + } + + pub fn similarity_threshold(&self) -> f64 { + self.config.similarity_threshold + } + + pub fn index_name(&self) -> &str { + &self.config.index_name + } + + fn index_state(&self) -> IndexState { + IndexState { + name: self.config.index_name.clone(), + prefix: format!("{}:", self.config.index_name), + dimension: Arc::clone(&self.index_dimension), + similarity_threshold: self.config.similarity_threshold, + } + } + + fn decode(&self, lookup: SemanticLookup>) -> Result, Error> { + Ok(SemanticLookup { + value: lookup + .value + .map(|bytes| self.codec.decode(&bytes)) + .transpose()?, + similarity: lookup.similarity, + }) + } +} + +impl ValkeySemanticCache +where + E: Embedder, + S: CacheCodec + Clone, + C: redis::ConnectionLike + Send + 'static, +{ + /// The same index and connections behind a different embedder. + pub fn with_embedder(&self, embedder: E2) -> ValkeySemanticCache { + ValkeySemanticCache { + connections: Arc::clone(&self.connections), + embedder, + codec: self.codec.clone(), + config: self.config.clone(), + index_dimension: Arc::clone(&self.index_dimension), + } + } +} + +impl BaseCache for ValkeySemanticCache +where + E: Embedder, + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + type Value = S::Value; + type Context = SemanticCacheContext; + + fn get_ttl(&self, context: &Self::Context) -> Option { + context.ttl + } + + fn set_cache( + &self, + key: &str, + value: Self::Value, + context: &Self::Context, + ) -> Result<(), Error> { + let Some(prompt) = prompt_from_context(context) else { + return Ok(()); + }; + let embedding = self.embedder.embed(&prompt, context.metadata.as_ref())?; + let response = self.codec.encode(&value)?; + let index = self.index_state(); + self.connections.execute(|connection| { + write_document( + connection, + &index, + &scope_tag(key), + &prompt, + response, + embedding_bytes(&embedding), + self.get_ttl(context), + ) + }) + } + + fn get_cache(&self, key: &str, context: &Self::Context) -> Result, Error> { + self.get_cache_with_similarity(key, context) + .map(|lookup| lookup.value) + } + + async fn async_set_cache( + &self, + key: &str, + value: Self::Value, + context: Self::Context, + ) -> Result<(), Error> { + let Some(prompt) = prompt_from_context(&context) else { + return Ok(()); + }; + let embedding = self + .embedder + .async_embed(&prompt, context.metadata.as_ref()) + .await?; + let response = self.codec.encode(&value)?; + let index = self.index_state(); + let scope = scope_tag(key); + Connections::run_blocking(Arc::clone(&self.connections), move |connection| { + write_document( + connection, + &index, + &scope, + &prompt, + response, + embedding_bytes(&embedding), + context.ttl, + ) + }) + .await + } + + async fn async_get_cache( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + self.async_get_cache_with_similarity(key, context) + .await + .map(|lookup| lookup.value) + } +} + +/// Python stamps a similarity of `0.0` when there is no prompt or no document in the key's +/// scope, and the closest document's similarity even when it misses the threshold. +impl SemanticCache for ValkeySemanticCache +where + E: Embedder, + S: CacheCodec, + C: redis::ConnectionLike + Send + 'static, +{ + fn get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + let Some(prompt) = prompt_from_context(context) else { + return Ok(SemanticLookup::miss(Some(0.0))); + }; + let embedding = self.embedder.embed(&prompt, context.metadata.as_ref())?; + let index = self.index_state(); + let lookup = self.connections.execute(|connection| { + search_document( + connection, + &index, + &scope_tag(key), + embedding_bytes(&embedding), + ) + })?; + self.decode(lookup) + } + + async fn async_get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error> { + let Some(prompt) = prompt_from_context(context) else { + return Ok(SemanticLookup::miss(Some(0.0))); + }; + let embedding = self + .embedder + .async_embed(&prompt, context.metadata.as_ref()) + .await?; + let index = self.index_state(); + let scope = scope_tag(key); + let lookup = Connections::run_blocking(Arc::clone(&self.connections), move |connection| { + search_document(connection, &index, &scope, embedding_bytes(&embedding)) + }) + .await?; + self.decode(lookup) + } +} diff --git a/litellm-rust/crates/cache-valkey-semantic/src/config.rs b/litellm-rust/crates/cache-valkey-semantic/src/config.rs new file mode 100644 index 00000000000..c417652c27b --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/src/config.rs @@ -0,0 +1,8 @@ +/// `ValkeySemanticCache.DEFAULT_VALKEY_INDEX_NAME`. +pub const DEFAULT_INDEX_NAME: &str = "litellm_semantic_cache_index"; + +#[derive(Clone, Debug, PartialEq)] +pub struct ValkeySemanticConfig { + pub similarity_threshold: f64, + pub index_name: String, +} diff --git a/litellm-rust/crates/cache-valkey-semantic/src/index.rs b/litellm-rust/crates/cache-valkey-semantic/src/index.rs new file mode 100644 index 00000000000..a0b7be4ca7b --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/src/index.rs @@ -0,0 +1,100 @@ +use std::sync::{Arc, Mutex}; + +use litellm_cache::Error; +use litellm_cache_redis::connection::ConnectionRef; + +use crate::search::value_text; + +/// The valkey-search index one cache writes to, with the dimension it was last ensured for. +#[derive(Clone)] +pub(crate) struct IndexState { + pub(crate) name: String, + pub(crate) prefix: String, + pub(crate) dimension: Arc>>, + pub(crate) similarity_threshold: f64, +} + +/// `_ensure_index_sync` / `_ensure_index_async`: create the TAG + HNSW index once per dimension, +/// and accept an existing index unless it reports a different dimension. +pub(crate) fn ensure_index( + connection: &mut ConnectionRef<'_>, + index: &IndexState, + dimension: usize, +) -> Result<(), Error> { + if index + .dimension + .lock() + .map_err(|_| Error::Unavailable)? + .is_some_and(|existing| existing == dimension) + { + return Ok(()); + } + let create = redis::cmd("FT.CREATE") + .arg(&index.name) + .arg("ON") + .arg("HASH") + .arg("PREFIX") + .arg(1) + .arg(&index.prefix) + .arg("SCHEMA") + .arg("litellm_cache_key") + .arg("TAG") + .arg("embedding") + .arg("VECTOR") + .arg("HNSW") + .arg(6) + .arg("TYPE") + .arg("FLOAT32") + .arg("DIM") + .arg(dimension) + .arg("DISTANCE_METRIC") + .arg("COSINE") + .query::(connection) + .map(|_| ()) + .map_err(|error| error.to_string()); + if let Err(message) = create { + if !message.to_ascii_lowercase().contains("already exists") { + return Err(Error::Unavailable); + } + let info = redis::cmd("FT.INFO") + .arg(&index.name) + .query::(connection) + .map_err(|_| Error::Unavailable)?; + if index_dimension_from_info(&info).is_some_and(|existing| existing != dimension) { + return Err(Error::Unavailable); + } + } + *index.dimension.lock().map_err(|_| Error::Unavailable)? = Some(dimension); + Ok(()) +} + +/// `_extract_index_dim`: flatten each attribute one level and read the value after +/// `dimensions`. +fn index_dimension_from_info(value: &redis::Value) -> Option { + let redis::Value::Array(values) = value else { + return None; + }; + let attributes = values.windows(2).find_map(|pair| { + (value_text(&pair[0]).as_deref() == Some("attributes")).then_some(&pair[1]) + })?; + let redis::Value::Array(fields) = attributes else { + return None; + }; + fields.iter().find_map(|field| { + let redis::Value::Array(values) = field else { + return None; + }; + let values = values + .iter() + .flat_map(|value| match value { + redis::Value::Array(values) => values.as_slice(), + _ => std::slice::from_ref(value), + }) + .collect::>(); + values.windows(2).find_map(|pair| { + (value_text(pair[0]).as_deref() == Some("dimensions")) + .then(|| value_text(pair[1]).and_then(|value| value.parse().ok())) + .flatten() + }) + }) +} diff --git a/litellm-rust/crates/cache-valkey-semantic/src/lib.rs b/litellm-rust/crates/cache-valkey-semantic/src/lib.rs index 6062ccc842c..f2f62bc95bd 100644 --- a/litellm-rust/crates/cache-valkey-semantic/src/lib.rs +++ b/litellm-rust/crates/cache-valkey-semantic/src/lib.rs @@ -1,1153 +1,7 @@ -use std::{ - future::Future, - sync::{Arc, Mutex}, - time::Duration, -}; +mod cache; +mod config; +mod index; +mod search; -use litellm_cache::{BaseCache, CacheCodec, CacheConnectionResult, Error, SemanticCacheContext}; -use litellm_cache_redis::{ - RedisTopology, - connection::{ConnectionRef, Connections}, -}; -use litellm_cache_response::CacheEntry; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -pub trait Embedder: Send + Sync + 'static { - fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error>; - - fn async_embed( - &self, - prompt: &str, - metadata: Option<&Value>, - ) -> impl Future, Error>> + Send; -} - -pub struct PreparedEmbedding(pub Vec); - -impl Embedder for PreparedEmbedding { - fn embed(&self, _prompt: &str, _metadata: Option<&Value>) -> Result, Error> { - Ok(self.0.clone()) - } - - async fn async_embed( - &self, - _prompt: &str, - _metadata: Option<&Value>, - ) -> Result, Error> { - Ok(self.0.clone()) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ValkeySemanticConfig { - pub similarity_threshold: f64, - pub index_name: String, -} - -pub const DEFAULT_INDEX_NAME: &str = "litellm_semantic_cache_index"; - -#[derive(Clone)] -struct IndexState { - name: String, - prefix: String, - dimension: Arc>>, - similarity_threshold: f64, -} - -pub struct ValkeySemanticCache< - E: Embedder, - S: CacheCodec, - C = redis::Connection, -> { - connections: Arc>, - embedder: E, - codec: S, - config: ValkeySemanticConfig, - index_dimension: Arc>>, -} - -impl ValkeySemanticCache -where - E: Embedder, - S: CacheCodec, -{ - pub fn new( - url: &str, - embedder: E, - codec: S, - config: ValkeySemanticConfig, - ) -> Result { - Ok(Self { - connections: Arc::new(Connections::open(url, &RedisTopology::Standalone)?), - embedder, - codec, - config, - index_dimension: Arc::new(Mutex::new(None)), - }) - } -} - -impl ValkeySemanticCache -where - E: Embedder, - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - pub fn with_connection( - connection: C, - embedder: E, - codec: S, - config: ValkeySemanticConfig, - ) -> Self { - Self { - connections: Arc::new(Connections::fixed(connection)), - embedder, - codec, - config, - index_dimension: Arc::new(Mutex::new(None)), - } - } - - pub fn similarity_threshold(&self) -> f64 { - self.config.similarity_threshold - } - - pub fn index_name(&self) -> &str { - &self.config.index_name - } - - fn index_state(&self) -> IndexState { - IndexState { - name: self.config.index_name.clone(), - prefix: format!("{}:", self.config.index_name), - dimension: Arc::clone(&self.index_dimension), - similarity_threshold: self.config.similarity_threshold, - } - } -} - -impl ValkeySemanticCache -where - E: Embedder, - S: CacheCodec + Clone, - C: redis::ConnectionLike + Send + 'static, -{ - pub fn with_embedder(&self, embedder: E2) -> ValkeySemanticCache { - ValkeySemanticCache { - connections: Arc::clone(&self.connections), - embedder, - codec: self.codec.clone(), - config: self.config.clone(), - index_dimension: Arc::clone(&self.index_dimension), - } - } -} - -impl BaseCache for ValkeySemanticCache -where - E: Embedder, - S: CacheCodec, - C: redis::ConnectionLike + Send + 'static, -{ - type Value = CacheEntry; - type Context = SemanticCacheContext; - - fn get_ttl(&self, context: &Self::Context) -> Option { - context.ttl - } - - fn set_cache( - &self, - key: &str, - value: Self::Value, - context: &Self::Context, - ) -> Result<(), Error> { - let Some(prompt) = prompt_from_context(context) else { - return Ok(()); - }; - let embedding = self.embedder.embed(&prompt, context.metadata.as_ref())?; - let scope = scope_tag(key); - let response = self.codec.encode(&value)?; - let vector = embedding_bytes(&embedding); - let index = self.index_state(); - self.connections.execute(|connection| { - write_document( - connection, - &index, - &scope, - &prompt, - response, - vector, - self.get_ttl(context), - ) - }) - } - - fn get_cache(&self, key: &str, context: &Self::Context) -> Result, Error> { - let Some(prompt) = prompt_from_context(context) else { - return Ok(None); - }; - let embedding = self.embedder.embed(&prompt, context.metadata.as_ref())?; - let scope = scope_tag(key); - let vector = embedding_bytes(&embedding); - let index = self.index_state(); - let response = self.connections.execute(|connection| { - search_document(connection, &index, &scope, vector, embedding.len()) - })?; - let Some(response) = response else { - return Ok(None); - }; - self.codec.decode(&response).map(Some) - } - - fn async_set_cache( - &self, - key: &str, - value: Self::Value, - context: Self::Context, - ) -> impl Future> + Send { - let key = key.to_owned(); - let prompt = prompt_from_context(&context); - let metadata = context.metadata.clone(); - async move { - let Some(prompt) = prompt else { - return Ok(()); - }; - let embedding = self - .embedder - .async_embed(&prompt, metadata.as_ref()) - .await?; - let connections = Arc::clone(&self.connections); - let index = self.index_state(); - let response = self.codec.encode(&value)?; - let vector = embedding_bytes(&embedding); - let scope = scope_tag(&key); - let ttl = context.ttl; - Connections::run_blocking(connections, move |connection| { - write_document(connection, &index, &scope, &prompt, response, vector, ttl) - }) - .await - } - } - - fn async_get_cache( - &self, - key: &str, - context: &Self::Context, - ) -> impl Future, Error>> + Send { - let key = key.to_owned(); - let prompt = prompt_from_context(context); - let metadata = context.metadata.clone(); - async move { - let Some(prompt) = prompt else { - return Ok(None); - }; - let embedding = self - .embedder - .async_embed(&prompt, metadata.as_ref()) - .await?; - let connections = Arc::clone(&self.connections); - let index = self.index_state(); - Connections::run_blocking(connections, move |connection| { - let scope = scope_tag(&key); - let vector = embedding_bytes(&embedding); - search_document(connection, &index, &scope, vector, embedding.len()) - }) - .await - .and_then(|response| response.map(|bytes| self.codec.decode(&bytes)).transpose()) - } - } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - Err(Error::UnsupportedOperation) - } -} - -pub fn prompt_from_context(context: &SemanticCacheContext) -> Option { - if let Some(Value::Array(messages)) = context.messages.as_ref() - && !messages.is_empty() - { - return messages - .iter() - .filter_map(Value::as_object) - .map(message_text) - .collect(); - } - let input = context.input.as_ref()?; - let mut parts = Vec::new(); - collect_input_text(input, &mut parts); - let prompt = parts.join("\n").trim().to_owned(); - (!prompt.is_empty()).then_some(prompt) -} - -fn message_text(message: &serde_json::Map) -> Option { - let content = match message.get("content") { - Some(Value::String(value)) => value.clone(), - Some(Value::Array(parts)) => { - let mut content = String::new(); - for part in parts { - let part = part.as_object()?; - if let Some(text) = part.get("text").and_then(Value::as_str) { - content.push_str(text); - } - } - content - } - _ => String::new(), - }; - Some(format!( - "{content}{}", - search_results_text(message.get("search_results")) - )) -} - -fn search_results_text(value: Option<&Value>) -> String { - let Some(Value::Array(results)) = value else { - return String::new(); - }; - results - .iter() - .filter_map(Value::as_object) - .map(|result| { - let source = result.get("source").and_then(Value::as_str).unwrap_or(""); - let title = result.get("title").and_then(Value::as_str).unwrap_or(""); - let content = result - .get("content") - .and_then(Value::as_array) - .map(|blocks| { - blocks - .iter() - .filter_map(Value::as_object) - .filter_map(|block| block.get("text").and_then(Value::as_str)) - .collect::() - }) - .unwrap_or_default(); - let citations = result - .get("citations") - .filter(|value| !value.is_null()) - .and_then(|value| serde_json::to_string(value).ok()) - .unwrap_or_default(); - format!("{source}{title}{content}{citations}") - }) - .collect() -} - -fn collect_input_text(value: &Value, parts: &mut Vec) { - match value { - Value::String(value) => { - let value = value.trim(); - if !value.is_empty() { - parts.push(value.to_owned()); - } - } - Value::Array(values) => values - .iter() - .for_each(|value| collect_input_text(value, parts)), - Value::Object(object) => { - if let Some(content) = object.get("content").filter(|value| !value.is_null()) { - collect_input_text(content, parts); - return; - } - for key in ["text", "output", "input_text", "output_text"] { - if let Some(Value::String(value)) = object.get(key) { - let value = value.trim(); - if !value.is_empty() { - parts.push(value.to_owned()); - return; - } - } - } - } - _ => {} - } -} - -fn scope_tag(key: &str) -> String { - let digest = Sha256::digest(key.as_bytes()); - digest.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn embedding_bytes(embedding: &[f32]) -> Vec { - embedding - .iter() - .flat_map(|value| value.to_le_bytes()) - .collect() -} - -fn write_document( - connection: &mut ConnectionRef<'_>, - index: &IndexState, - scope: &str, - prompt: &str, - response: Vec, - vector: Vec, - ttl: Option, -) -> Result<(), Error> { - let dimension = vector.len() / std::mem::size_of::(); - ensure_index( - connection, - &index.name, - &index.prefix, - &index.dimension, - dimension, - )?; - let document = format!("{}{scope}:{}", index.prefix, Uuid::new_v4()); - let mut pipeline = redis::pipe(); - pipeline - .cmd("HSET") - .arg(&document) - .arg("litellm_cache_key") - .arg(scope) - .arg("prompt") - .arg(prompt) - .arg("response") - .arg(response) - .arg("embedding") - .arg(vector) - .ignore(); - if let Some(ttl) = ttl { - pipeline - .cmd("EXPIRE") - .arg(&document) - .arg(ttl.as_secs()) - .ignore(); - } - pipeline - .query::<()>(connection) - .map_err(|_| Error::Unavailable) -} - -fn search_document( - connection: &mut ConnectionRef<'_>, - index: &IndexState, - scope: &str, - vector: Vec, - dimension: usize, -) -> Result>, Error> { - ensure_index( - connection, - &index.name, - &index.prefix, - &index.dimension, - dimension, - )?; - let query = - format!("(@litellm_cache_key:{{{scope}}})=>[KNN 1 @embedding $vec AS vector_distance]"); - let response = redis::cmd("FT.SEARCH") - .arg(&index.name) - .arg(query) - .arg("PARAMS") - .arg(2) - .arg("vec") - .arg(vector) - .arg("RETURN") - .arg(2) - .arg("response") - .arg("vector_distance") - .arg("DIALECT") - .arg(2) - .query::(connection) - .map_err(|_| Error::Unavailable)?; - let Some(fields) = search_fields(response)? else { - return Ok(None); - }; - let response = fields - .iter() - .find_map(|(name, value)| (name == "response").then(|| value.clone())) - .ok_or(Error::InvalidEntry)?; - let distance = fields - .iter() - .find_map(|(name, value)| (name == "vector_distance").then(|| value.clone())) - .ok_or(Error::InvalidEntry)?; - let distance = parse_f64(&distance)?; - if 1.0 - distance < index.similarity_threshold { - return Ok(None); - } - Ok(Some(response)) -} - -fn ensure_index( - connection: &mut ConnectionRef<'_>, - index_name: &str, - prefix: &str, - index_dimension: &Mutex>, - dimension: usize, -) -> Result<(), Error> { - if index_dimension - .lock() - .map_err(|_| Error::Unavailable)? - .is_some_and(|existing| existing == dimension) - { - return Ok(()); - } - let create = redis::cmd("FT.CREATE") - .arg(index_name) - .arg("ON") - .arg("HASH") - .arg("PREFIX") - .arg(1) - .arg(prefix) - .arg("SCHEMA") - .arg("litellm_cache_key") - .arg("TAG") - .arg("embedding") - .arg("VECTOR") - .arg("HNSW") - .arg(6) - .arg("TYPE") - .arg("FLOAT32") - .arg("DIM") - .arg(dimension) - .arg("DISTANCE_METRIC") - .arg("COSINE") - .query::(connection) - .map(|_| ()) - .map_err(|error| error.to_string()); - if let Err(message) = create { - if !message.to_ascii_lowercase().contains("already exists") { - return Err(Error::Unavailable); - } - let info = redis::cmd("FT.INFO") - .arg(index_name) - .query::(connection) - .map_err(|_| Error::Unavailable)?; - let existing = index_dimension_from_info(&info).ok_or(Error::Unavailable)?; - if existing != dimension { - return Err(Error::Unavailable); - } - } - *index_dimension.lock().map_err(|_| Error::Unavailable)? = Some(dimension); - Ok(()) -} - -fn index_dimension_from_info(value: &redis::Value) -> Option { - let redis::Value::Array(values) = value else { - return None; - }; - let attributes = values.windows(2).find_map(|pair| { - (value_text(&pair[0]).as_deref() == Some("attributes")).then_some(&pair[1]) - })?; - let redis::Value::Array(fields) = attributes else { - return None; - }; - fields.iter().find_map(|field| { - let redis::Value::Array(values) = field else { - return None; - }; - let flattened = values.iter().flat_map(|value| match value { - redis::Value::Array(values) => values.as_slice(), - _ => std::slice::from_ref(value), - }); - let values = flattened.collect::>(); - values.windows(2).find_map(|pair| { - if value_text(pair[0]).as_deref() == Some("dimensions") { - return value_text(pair[1]).and_then(|value| value.parse().ok()); - } - None - }) - }) -} - -type SearchFields = Vec<(String, Vec)>; - -fn search_fields(value: redis::Value) -> Result, Error> { - let redis::Value::Array(values) = value else { - return Err(Error::InvalidEntry); - }; - let total = parse_i64(values.first().ok_or(Error::InvalidEntry)?)?; - if total <= 0 || values.len() < 3 { - return Ok(None); - } - let redis::Value::Array(fields) = &values[2] else { - return Err(Error::InvalidEntry); - }; - let (pairs, remainder) = fields.as_chunks::<2>(); - if !remainder.is_empty() { - return Err(Error::InvalidEntry); - } - let pairs = pairs - .iter() - .map(|pair| { - Ok(( - value_text(&pair[0]).ok_or(Error::InvalidEntry)?, - value_bytes(&pair[1])?, - )) - }) - .collect::, Error>>()?; - Ok(Some(pairs)) -} - -fn parse_i64(value: &redis::Value) -> Result { - value_text(value) - .ok_or(Error::InvalidEntry)? - .parse() - .map_err(|_| Error::InvalidEntry) -} - -fn parse_f64(value: &[u8]) -> Result { - std::str::from_utf8(value) - .map_err(|_| Error::InvalidEntry)? - .parse() - .map_err(|_| Error::InvalidEntry) -} - -fn value_text(value: &redis::Value) -> Option { - match value { - redis::Value::BulkString(bytes) => String::from_utf8(bytes.clone()).ok(), - redis::Value::SimpleString(value) => Some(value.clone()), - redis::Value::Int(value) => Some(value.to_string()), - _ => None, - } -} - -fn value_bytes(value: &redis::Value) -> Result, Error> { - match value { - redis::Value::BulkString(bytes) => Ok(bytes.clone()), - redis::Value::SimpleString(value) => Ok(value.as_bytes().to_vec()), - redis::Value::Int(value) => Ok(value.to_string().into_bytes()), - _ => Err(Error::InvalidEntry), - } -} - -#[cfg(test)] -mod tests { - use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - time::Duration, - }; - - use litellm_cache::{BaseCache, CacheCodec}; - use litellm_cache_response::{ - CacheEntry, CacheKeyInput, ResponseCache, ResponseCacheCodec, ResponseCacheRequest, - }; - use redis_test::MockRedisConnection; - use rstest::rstest; - use serde_json::{Value, json}; - - use super::{ - Embedder, PreparedEmbedding, ValkeySemanticCache, ValkeySemanticConfig, - index_dimension_from_info, prompt_from_context, scope_tag, - }; - - #[derive(Clone)] - struct FixedEmbedder { - vector: Vec, - calls: EmbedderCalls, - } - - type EmbedderCalls = Arc)>>>; - type RecordingCache = - ValkeySemanticCache; - type RecordingSetup = (RecordingCache, Arc>>>, EmbedderCalls); - - impl Embedder for FixedEmbedder { - fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, super::Error> { - self.calls - .lock() - .unwrap() - .push((prompt.into(), metadata.cloned())); - Ok(self.vector.clone()) - } - - async fn async_embed( - &self, - prompt: &str, - metadata: Option<&Value>, - ) -> Result, super::Error> { - self.embed(prompt, metadata) - } - } - - struct RecordingConnection { - requests: Arc>>>, - replies: Mutex>>, - } - - impl RecordingConnection { - fn new(replies: impl IntoIterator>) -> Self { - Self { - requests: Arc::default(), - replies: Mutex::new(replies.into_iter().collect()), - } - } - - fn requests(&self) -> Arc>>> { - Arc::clone(&self.requests) - } - - fn reply(&self) -> redis::RedisResult { - self.replies - .lock() - .unwrap() - .pop_front() - .unwrap_or_else(|| Ok(redis::Value::SimpleString("OK".into()))) - } - } - - impl redis::ConnectionLike for RecordingConnection { - fn req_packed_command(&mut self, command: &[u8]) -> redis::RedisResult { - self.requests.lock().unwrap().push(command.to_vec()); - self.reply() - } - - fn req_packed_commands( - &mut self, - command: &[u8], - _offset: usize, - count: usize, - ) -> redis::RedisResult> { - self.requests.lock().unwrap().push(command.to_vec()); - (0..count).map(|_| self.reply()).collect() - } - - fn get_db(&self) -> i64 { - 0 - } - - fn check_connection(&mut self) -> bool { - true - } - - fn is_open(&self) -> bool { - true - } - } - - fn context( - messages: Option, - input: Option, - ) -> litellm_cache::SemanticCacheContext { - litellm_cache::SemanticCacheContext { - messages, - input, - ..Default::default() - } - } - - #[rstest] - #[case(json!([{"content": "hello"}]), None, Some("hello"))] - #[case(json!([{"content": [{"text": "hello"}, {"text": " world"}]}]), None, Some("hello world"))] - #[case(json!([{"content": ["raw", {"text": "hello"}]}]), None, None)] - #[case(json!([{"search_results": [{"source": "s", "title": "t", "content": [{"text": "c"}], "citations": ["x"]}]}]), None, Some(r#"stc["x"]"#))] - #[case(Value::Array(vec![]), Some(json!(" hello ")), Some("hello"))] - #[case(Value::Array(vec![]), Some(json!([{"content": "first"}, {"text": "second"}])), Some("first\nsecond"))] - #[case(Value::Array(vec![]), Some(json!(" ")), None)] - fn prompt_shapes( - #[case] messages: Value, - #[case] input: Option, - #[case] expected: Option<&str>, - ) { - assert_eq!( - prompt_from_context(&context(Some(messages), input)), - expected.map(str::to_owned) - ); - } - - #[test] - fn scope_tags_are_lowercase_sha256() { - assert_eq!( - scope_tag("key"), - "2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683" - ); - } - - #[test] - fn existing_index_dimension_is_read_from_attributes() { - let info = redis::Value::Array(vec![ - redis::Value::SimpleString("attributes".into()), - redis::Value::Array(vec![redis::Value::Array(vec![ - redis::Value::SimpleString("identifier".into()), - redis::Value::SimpleString("embedding".into()), - redis::Value::Array(vec![ - redis::Value::SimpleString("dimensions".into()), - redis::Value::SimpleString("2".into()), - ]), - ])]), - ]); - assert_eq!(index_dimension_from_info(&info), Some(2)); - } - - #[tokio::test] - async fn unsupported_connection_test_is_reported() { - let cache = ValkeySemanticCache::with_connection( - MockRedisConnection::new([]).assert_all_commands_consumed(), - FixedEmbedder { - vector: vec![1.0, 0.0], - calls: Arc::default(), - }, - ResponseCacheCodec, - ValkeySemanticConfig { - similarity_threshold: 0.8, - index_name: "test".into(), - }, - ); - assert_eq!( - cache.test_connection().await, - Err(super::Error::UnsupportedOperation) - ); - } - - #[tokio::test] - async fn prepared_embedding_returns_its_vector_for_any_prompt() { - let embedding = PreparedEmbedding(vec![1.0, 2.0]); - assert_eq!( - embedding - .async_embed("different prompt", None) - .await - .unwrap(), - vec![1.0, 2.0] - ); - } - - #[test] - fn with_embedder_shares_index_state_and_connections() { - let entry = CacheEntry { - timestamp: Some(1.0), - response: json!({"answer": "ok"}), - }; - let encoded = ResponseCacheCodec.encode(&entry).unwrap(); - let cache = ValkeySemanticCache::with_connection( - RecordingConnection::new([ok(), ok(), Ok(search_hit(encoded, "0.1"))]), - FixedEmbedder { - vector: vec![1.0, 0.0], - calls: Arc::default(), - }, - ResponseCacheCodec, - ValkeySemanticConfig { - similarity_threshold: 0.8, - index_name: "test".into(), - }, - ); - cache - .set_cache("key", entry.clone(), &semantic_context(None)) - .unwrap(); - let prepared = cache.with_embedder(PreparedEmbedding(vec![1.0, 0.0])); - assert_eq!( - prepared.get_cache("key", &semantic_context(None)).unwrap(), - Some(entry) - ); - } - - #[test] - fn missing_prompt_does_not_touch_redis() { - let cache = ValkeySemanticCache::with_connection( - MockRedisConnection::new([]).assert_all_commands_consumed(), - FixedEmbedder { - vector: vec![1.0, 0.0], - calls: Arc::default(), - }, - ResponseCacheCodec, - ValkeySemanticConfig { - similarity_threshold: 0.8, - index_name: "test".into(), - }, - ); - assert_eq!(cache.get_cache("key", &context(None, None)).unwrap(), None); - assert_eq!(cache.get_ttl(&context(None, None)), None); - } - - fn semantic_context(ttl: Option) -> litellm_cache::SemanticCacheContext { - litellm_cache::SemanticCacheContext { - messages: Some(json!([{"role": "user", "content": "hello"}])), - metadata: Some(json!({"source": "test"})), - ttl, - ..Default::default() - } - } - - fn cache_with_recording( - replies: impl IntoIterator>, - vector: Vec, - threshold: f64, - ) -> RecordingSetup { - let connection = RecordingConnection::new(replies); - let requests = connection.requests(); - let calls: EmbedderCalls = Arc::default(); - let cache = ValkeySemanticCache::with_connection( - connection, - FixedEmbedder { - vector, - calls: Arc::clone(&calls), - }, - ResponseCacheCodec, - ValkeySemanticConfig { - similarity_threshold: threshold, - index_name: "test".into(), - }, - ); - (cache, requests, calls) - } - - fn ok() -> redis::RedisResult { - Ok(redis::Value::SimpleString("OK".into())) - } - - fn already_exists() -> redis::RedisResult { - Err(redis::RedisError::from(( - redis::ErrorKind::Io, - "already exists", - ))) - } - - fn info_dimension(dimension: usize) -> redis::Value { - redis::Value::Array(vec![ - redis::Value::SimpleString("attributes".into()), - redis::Value::Array(vec![redis::Value::Array(vec![ - redis::Value::SimpleString("embedding".into()), - redis::Value::Array(vec![ - redis::Value::SimpleString("dimensions".into()), - redis::Value::Int(dimension as i64), - ]), - ])]), - ]) - } - - fn search_hit(response: Vec, distance: &str) -> redis::Value { - redis::Value::Array(vec![ - redis::Value::Int(1), - redis::Value::BulkString(b"test:document".to_vec()), - redis::Value::Array(vec![ - redis::Value::BulkString(b"response".to_vec()), - redis::Value::BulkString(response), - redis::Value::BulkString(b"vector_distance".to_vec()), - redis::Value::BulkString(distance.as_bytes().to_vec()), - ]), - ]) - } - - fn requests_text(requests: &Arc>>>) -> String { - requests - .lock() - .unwrap() - .iter() - .map(|request| String::from_utf8_lossy(request)) - .collect::>() - .join("\n") - } - - #[test] - fn set_without_ttl_writes_hset_without_expire() { - let (cache, requests, calls) = cache_with_recording([ok()], vec![1.0, 0.0], 0.8); - cache - .set_cache( - "key", - CacheEntry { - timestamp: None, - response: json!({"answer": "ok"}), - }, - &semantic_context(None), - ) - .unwrap(); - let text = requests_text(&requests); - assert!(text.contains("FT.CREATE")); - assert!(text.contains("HSET")); - assert!( - text.contains("test:2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683:") - ); - assert!(!text.contains("EXPIRE")); - assert_eq!( - *calls.lock().unwrap(), - vec![("hello".into(), Some(json!({"source": "test"})))] - ); - } - - #[test] - fn set_with_ttl_truncates_expire_seconds() { - let (cache, requests, _) = cache_with_recording([ok()], vec![1.0, 0.0], 0.8); - cache - .set_cache( - "key", - CacheEntry { - timestamp: None, - response: json!({"answer": "ok"}), - }, - &semantic_context(Some(Duration::from_millis(1900))), - ) - .unwrap(); - let text = requests_text(&requests); - assert!(text.contains("EXPIRE")); - assert!(text.contains("\r\n$1\r\n1\r\n")); - } - - #[test] - fn second_set_skips_create_after_dimension_is_cached() { - let (cache, requests, _) = cache_with_recording([ok()], vec![1.0, 0.0], 0.8); - let context = semantic_context(None); - let entry = CacheEntry { - timestamp: None, - response: json!({"answer": "ok"}), - }; - cache.set_cache("key", entry.clone(), &context).unwrap(); - cache.set_cache("key", entry, &context).unwrap(); - let text = requests_text(&requests); - assert_eq!(text.matches("FT.CREATE").count(), 1); - assert_eq!(text.matches("HSET").count(), 2); - } - - #[test] - fn existing_index_dimension_must_match_embedding() { - let (cache, _, _) = cache_with_recording( - [already_exists(), Ok(info_dimension(2))], - vec![1.0, 0.0], - 0.8, - ); - cache - .set_cache( - "key", - CacheEntry { - timestamp: None, - response: json!({"answer": "ok"}), - }, - &semantic_context(None), - ) - .unwrap(); - - let (cache, _, _) = cache_with_recording( - [already_exists(), Ok(info_dimension(3))], - vec![1.0, 0.0], - 0.8, - ); - assert_eq!( - cache.set_cache( - "key", - CacheEntry { - timestamp: None, - response: json!({"answer": "ok"}), - }, - &semantic_context(None), - ), - Err(super::Error::Unavailable) - ); - } - - #[test] - fn get_applies_threshold_and_decodes_entry() { - let entry = CacheEntry { - timestamp: Some(1.0), - response: json!({"answer": "ok"}), - }; - let encoded = ResponseCacheCodec.encode(&entry).unwrap(); - let (cache, _, _) = cache_with_recording( - [ok(), Ok(search_hit(encoded.clone(), "0.1"))], - vec![1.0, 0.0], - 0.8, - ); - assert_eq!( - cache.get_cache("key", &semantic_context(None)).unwrap(), - Some(entry) - ); - - let (cache, _, _) = - cache_with_recording([ok(), Ok(search_hit(encoded, "0.5"))], vec![1.0, 0.0], 0.8); - assert_eq!( - cache.get_cache("key", &semantic_context(None)).unwrap(), - None - ); - } - - #[test] - fn get_zero_docs_is_a_miss() { - let (cache, _, _) = cache_with_recording( - [ok(), Ok(redis::Value::Array(vec![redis::Value::Int(0)]))], - vec![1.0, 0.0], - 0.8, - ); - assert_eq!( - cache.get_cache("key", &semantic_context(None)).unwrap(), - None - ); - } - - #[rstest] - #[case(redis::Value::Array(vec![ - redis::Value::Int(1), - redis::Value::BulkString(b"document".to_vec()), - redis::Value::Array(vec![ - redis::Value::BulkString(b"vector_distance".to_vec()), - redis::Value::BulkString(b"0.1".to_vec()), - ]), - ]))] - #[case(redis::Value::Array(vec![ - redis::Value::Int(1), - redis::Value::BulkString(b"document".to_vec()), - redis::Value::Array(vec![ - redis::Value::BulkString(b"response".to_vec()), - redis::Value::BulkString(b"not-json".to_vec()), - redis::Value::BulkString(b"vector_distance".to_vec()), - redis::Value::BulkString(b"abc".to_vec()), - ]), - ]))] - fn malformed_entries_are_invalid(#[case] search: redis::Value) { - let (cache, _, _) = cache_with_recording([ok(), Ok(search)], vec![1.0, 0.0], 0.8); - assert_eq!( - cache.get_cache("key", &semantic_context(None)), - Err(super::Error::InvalidEntry) - ); - } - - #[test] - fn response_cache_turns_invalid_entries_into_misses() { - let (cache, _, _) = cache_with_recording( - [ - ok(), - Ok(redis::Value::Array(vec![ - redis::Value::Int(1), - redis::Value::BulkString(b"document".to_vec()), - redis::Value::Array(vec![ - redis::Value::BulkString(b"response".to_vec()), - redis::Value::BulkString(b"not-json".to_vec()), - redis::Value::BulkString(b"vector_distance".to_vec()), - redis::Value::BulkString(b"0.1".to_vec()), - ]), - ])), - ], - vec![1.0, 0.0], - 0.8, - ); - let service = ResponseCache::new(Arc::new(cache)); - let request = ResponseCacheRequest { - key: CacheKeyInput { - preset: Some("key".into()), - ..Default::default() - }, - context: semantic_context(None), - ..ResponseCacheRequest::new(CacheKeyInput::default()) - }; - assert_eq!(service.lookup(&request, Duration::ZERO).unwrap(), None); - } - - #[tokio::test] - async fn async_set_and_get_use_shared_document_helpers() { - let entry = CacheEntry { - timestamp: Some(1.0), - response: json!({"answer": "ok"}), - }; - let encoded = ResponseCacheCodec.encode(&entry).unwrap(); - let (cache, requests, calls) = cache_with_recording( - [ok(), ok(), ok(), Ok(search_hit(encoded, "0.1"))], - vec![1.0, 0.0], - 0.8, - ); - let context = semantic_context(Some(Duration::from_millis(1900))); - cache - .async_set_cache("key", entry.clone(), context.clone()) - .await - .unwrap(); - assert_eq!( - cache.async_get_cache("key", &context).await.unwrap(), - Some(entry) - ); - let text = requests_text(&requests); - assert!(text.contains("FT.CREATE")); - assert!(text.contains("HSET")); - assert!(text.contains("EXPIRE")); - assert_eq!(calls.lock().unwrap().len(), 2); - } -} +pub use cache::ValkeySemanticCache; +pub use config::{DEFAULT_INDEX_NAME, ValkeySemanticConfig}; diff --git a/litellm-rust/crates/cache-valkey-semantic/src/search.rs b/litellm-rust/crates/cache-valkey-semantic/src/search.rs new file mode 100644 index 00000000000..3b4fae84317 --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/src/search.rs @@ -0,0 +1,163 @@ +use std::time::Duration; + +use litellm_cache::{Error, semantic::SemanticLookup}; +use litellm_cache_redis::connection::ConnectionRef; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::index::{IndexState, ensure_index}; + +/// `_scope_tag`: valkey-search TAG fields cannot match arbitrary keys verbatim, so scopes are +/// the key's lowercase SHA-256. +pub(crate) fn scope_tag(key: &str) -> String { + let digest = Sha256::digest(key.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub(crate) fn embedding_bytes(embedding: &[f32]) -> Vec { + embedding + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() +} + +/// `HSET` a fresh `:` document, then `EXPIRE` it when a TTL is set. +pub(crate) fn write_document( + connection: &mut ConnectionRef<'_>, + index: &IndexState, + scope: &str, + prompt: &str, + response: Vec, + vector: Vec, + ttl: Option, +) -> Result<(), Error> { + ensure_index(connection, index, vector.len() / size_of::())?; + let document = format!("{}{scope}:{}", index.prefix, Uuid::new_v4()); + let mut pipeline = redis::pipe(); + pipeline + .cmd("HSET") + .arg(&document) + .arg("litellm_cache_key") + .arg(scope) + .arg("prompt") + .arg(prompt) + .arg("response") + .arg(response) + .arg("embedding") + .arg(vector) + .ignore(); + if let Some(ttl) = ttl { + pipeline + .cmd("EXPIRE") + .arg(&document) + .arg(ttl.as_secs()) + .ignore(); + } + pipeline + .query::<()>(connection) + .map_err(|_| Error::Unavailable) +} + +/// The KNN-1 search within `scope`: the closest document's similarity, and its stored response +/// when that similarity reaches the threshold. No document reads as a similarity of `0.0`. +pub(crate) fn search_document( + connection: &mut ConnectionRef<'_>, + index: &IndexState, + scope: &str, + vector: Vec, +) -> Result>, Error> { + ensure_index(connection, index, vector.len() / size_of::())?; + let query = + format!("(@litellm_cache_key:{{{scope}}})=>[KNN 1 @embedding $vec AS vector_distance]"); + let response = redis::cmd("FT.SEARCH") + .arg(&index.name) + .arg(query) + .arg("PARAMS") + .arg(2) + .arg("vec") + .arg(vector) + .arg("RETURN") + .arg(2) + .arg("response") + .arg("vector_distance") + .arg("DIALECT") + .arg(2) + .query::(connection) + .map_err(|_| Error::Unavailable)?; + let Some(fields) = search_fields(response)? else { + return Ok(SemanticLookup::miss(Some(0.0))); + }; + let field = |name: &str| { + fields + .iter() + .find_map(|(field, value)| (field == name).then(|| value.clone())) + .ok_or(Error::InvalidEntry) + }; + let response = field("response")?; + let similarity = 1.0 - parse_f64(&field("vector_distance")?)?; + Ok(SemanticLookup { + value: (similarity >= index.similarity_threshold).then_some(response), + similarity: Some(similarity), + }) +} + +type SearchFields = Vec<(String, Vec)>; + +fn search_fields(value: redis::Value) -> Result, Error> { + let redis::Value::Array(values) = value else { + return Err(Error::InvalidEntry); + }; + let total = parse_i64(values.first().ok_or(Error::InvalidEntry)?)?; + if total <= 0 || values.len() < 3 { + return Ok(None); + } + let redis::Value::Array(fields) = &values[2] else { + return Err(Error::InvalidEntry); + }; + let (pairs, remainder) = fields.as_chunks::<2>(); + if !remainder.is_empty() { + return Err(Error::InvalidEntry); + } + pairs + .iter() + .map(|pair| { + Ok(( + value_text(&pair[0]).ok_or(Error::InvalidEntry)?, + value_bytes(&pair[1])?, + )) + }) + .collect::, Error>>() + .map(Some) +} + +fn parse_i64(value: &redis::Value) -> Result { + value_text(value) + .ok_or(Error::InvalidEntry)? + .parse() + .map_err(|_| Error::InvalidEntry) +} + +fn parse_f64(value: &[u8]) -> Result { + std::str::from_utf8(value) + .map_err(|_| Error::InvalidEntry)? + .parse() + .map_err(|_| Error::InvalidEntry) +} + +pub(crate) fn value_text(value: &redis::Value) -> Option { + match value { + redis::Value::BulkString(bytes) => String::from_utf8(bytes.clone()).ok(), + redis::Value::SimpleString(value) => Some(value.clone()), + redis::Value::Int(value) => Some(value.to_string()), + _ => None, + } +} + +fn value_bytes(value: &redis::Value) -> Result, Error> { + match value { + redis::Value::BulkString(bytes) => Ok(bytes.clone()), + redis::Value::SimpleString(value) => Ok(value.as_bytes().to_vec()), + redis::Value::Int(value) => Ok(value.to_string().into_bytes()), + _ => Err(Error::InvalidEntry), + } +} diff --git a/litellm-rust/crates/cache-valkey-semantic/tests/cache.rs b/litellm-rust/crates/cache-valkey-semantic/tests/cache.rs new file mode 100644 index 00000000000..1c693d0875d --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/tests/cache.rs @@ -0,0 +1,417 @@ +mod support; + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use litellm_cache::{ + BaseCache, Error, JsonCodec, SemanticCacheContext, + semantic::{PreparedEmbedding, SemanticCache, SemanticLookup}, +}; +use litellm_cache_valkey_semantic::{ + DEFAULT_INDEX_NAME, ValkeySemanticCache, ValkeySemanticConfig, +}; +use redis_test::MockRedisConnection; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use support::{EmbedCalls, FakeEmbedder, RecordingConnection}; + +type Requests = Arc>>>; +type RecordingCache = ValkeySemanticCache, RecordingConnection>; + +const KEY_SCOPE: &str = "2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683"; + +struct Recording { + cache: RecordingCache, + requests: Requests, + calls: EmbedCalls, +} + +impl Recording { + fn text(&self) -> String { + self.requests + .lock() + .unwrap() + .iter() + .map(|request| String::from_utf8_lossy(request).into_owned()) + .collect::>() + .join("\n") + } +} + +fn config() -> ValkeySemanticConfig { + ValkeySemanticConfig { + similarity_threshold: 0.8, + index_name: "test".into(), + } +} + +fn recording(replies: impl IntoIterator>) -> Recording { + let connection = RecordingConnection::new(replies); + let requests = connection.requests(); + let embedder = FakeEmbedder::new(&[]); + let calls = Arc::clone(&embedder.calls); + Recording { + cache: ValkeySemanticCache::with_connection( + connection, + embedder, + JsonCodec::new(), + config(), + ), + requests, + calls, + } +} + +#[fixture] +fn entry() -> Value { + json!({"timestamp": 1.0, "response": {"answer": "ok"}}) +} + +#[fixture] +fn context() -> SemanticCacheContext { + SemanticCacheContext { + messages: Some(json!([{"role": "user", "content": "hello"}])), + metadata: Some(json!({"source": "test"})), + ..Default::default() + } +} + +fn ok() -> redis::RedisResult { + Ok(redis::Value::SimpleString("OK".into())) +} + +fn already_exists() -> redis::RedisResult { + Err(redis::RedisError::from(( + redis::ErrorKind::Io, + "already exists", + ))) +} + +fn bulk(value: &[u8]) -> redis::Value { + redis::Value::BulkString(value.to_vec()) +} + +fn search_hit(response: &[u8], distance: &str) -> redis::Value { + redis::Value::Array(vec![ + redis::Value::Int(1), + bulk(b"test:document"), + redis::Value::Array(vec![ + bulk(b"response"), + bulk(response), + bulk(b"vector_distance"), + bulk(distance.as_bytes()), + ]), + ]) +} + +fn encoded(value: &Value) -> Vec { + serde_json::to_vec(value).unwrap() +} + +/// `FT.INFO` with the vector field's dimension nested one level down, as valkey-search reports. +fn nested_dimension_info(dimension: i64) -> redis::Value { + redis::Value::Array(vec![ + redis::Value::SimpleString("attributes".into()), + redis::Value::Array(vec![redis::Value::Array(vec![ + redis::Value::SimpleString("embedding".into()), + redis::Value::Array(vec![ + redis::Value::SimpleString("dimensions".into()), + redis::Value::Int(dimension), + ]), + ])]), + ]) +} + +/// `FT.INFO` with `dimensions` as a sibling string of the identifier. +fn flat_dimension_info(dimension: &str) -> redis::Value { + redis::Value::Array(vec![ + redis::Value::SimpleString("attributes".into()), + redis::Value::Array(vec![redis::Value::Array(vec![ + redis::Value::SimpleString("identifier".into()), + redis::Value::SimpleString("embedding".into()), + redis::Value::Array(vec![ + redis::Value::SimpleString("dimensions".into()), + redis::Value::SimpleString(dimension.into()), + ]), + ])]), + ]) +} + +#[rstest] +#[case::string_content(json!([{"content": "hello"}]), None, Some("hello"))] +#[case::text_parts(json!([{"content": [{"text": "hello"}, {"text": " world"}]}]), None, Some("hello world"))] +#[case::non_object_parts_are_skipped(json!([{"content": ["raw", {"text": "hello"}]}]), None, Some("hello"))] +#[case::search_results(json!([{"search_results": [{"source": "s", "title": "t", "content": [{"text": "c"}], "citations": ["x"]}]}]), None, Some(r#"stc["x"]"#))] +#[case::responses_string_input(json!([]), Some(json!(" hello ")), Some("hello"))] +#[case::responses_item_input(json!([]), Some(json!([{"content": "first"}, {"text": "second"}])), Some("first\nsecond"))] +#[case::blank_input(json!([]), Some(json!(" ")), None)] +fn prompt_shapes_follow_redis_semantic_extraction( + #[case] messages: Value, + #[case] input: Option, + #[case] expected: Option<&str>, +) { + let recording = recording([ok(), Ok(redis::Value::Array(vec![redis::Value::Int(0)]))]); + let context = SemanticCacheContext { + messages: Some(messages), + input, + ..Default::default() + }; + + assert_eq!(recording.cache.get_cache("key", &context).unwrap(), None); + + let prompts = recording + .calls + .lock() + .unwrap() + .iter() + .map(|(prompt, _)| prompt.clone()) + .collect::>(); + assert_eq!( + prompts, + expected.into_iter().map(str::to_owned).collect::>() + ); + assert_eq!( + recording.requests.lock().unwrap().is_empty(), + expected.is_none() + ); +} + +#[rstest] +#[case::key("key", KEY_SCOPE)] +#[case::empty_key("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] +fn documents_are_scoped_by_the_keys_sha256( + #[case] key: &str, + #[case] scope: &str, + entry: Value, + context: SemanticCacheContext, +) { + let recording = recording([ok()]); + + recording.cache.set_cache(key, entry, &context).unwrap(); + + let text = recording.text(); + assert!(text.contains(&format!("test:{scope}:"))); + assert!(text.contains(&format!("litellm_cache_key\r\n$64\r\n{scope}\r\n"))); +} + +#[rstest] +#[case::no_ttl(None, None)] +#[case::whole_seconds(Some(Duration::from_secs(5)), Some("5"))] +#[case::fractional_seconds_truncate(Some(Duration::from_millis(1900)), Some("1"))] +fn set_writes_hset_and_expires_only_with_a_ttl( + #[case] ttl: Option, + #[case] expire: Option<&str>, + entry: Value, + context: SemanticCacheContext, +) { + let recording = recording([ok()]); + + recording + .cache + .set_cache( + "key", + entry, + &SemanticCacheContext { + ttl, + ..context.clone() + }, + ) + .unwrap(); + + let text = recording.text(); + assert!(text.contains("FT.CREATE")); + assert!(text.contains("HSET")); + let expire_seconds = text + .split_once("EXPIRE\r\n") + .and_then(|(_, rest)| rest.split("\r\n").nth(3)); + assert_eq!(expire_seconds, expire); + assert_eq!( + *recording.calls.lock().unwrap(), + vec![("hello".to_owned(), context.metadata)] + ); +} + +#[rstest] +fn second_set_skips_create_after_dimension_is_cached(entry: Value, context: SemanticCacheContext) { + let recording = recording([ok()]); + + recording + .cache + .set_cache("key", entry.clone(), &context) + .unwrap(); + recording.cache.set_cache("key", entry, &context).unwrap(); + + let text = recording.text(); + assert_eq!(text.matches("FT.CREATE").count(), 1); + assert_eq!(text.matches("HSET").count(), 2); +} + +#[rstest] +#[case::nested_matching(nested_dimension_info(3), Ok(()))] +#[case::nested_mismatch(nested_dimension_info(2), Err(Error::Unavailable))] +#[case::flat_matching(flat_dimension_info("3"), Ok(()))] +#[case::flat_mismatch(flat_dimension_info("2"), Err(Error::Unavailable))] +#[case::unreported_dimension_is_accepted(redis::Value::Array(vec![]), Ok(()))] +fn existing_index_dimension_must_match_embedding( + #[case] info: redis::Value, + #[case] expected: Result<(), Error>, + entry: Value, + context: SemanticCacheContext, +) { + let recording = recording([already_exists(), Ok(info)]); + + assert_eq!(recording.cache.set_cache("key", entry, &context), expected); +} + +#[rstest] +#[case::create_failure(Err(redis::RedisError::from((redis::ErrorKind::Io, "boom"))))] +fn index_creation_failures_are_unavailable( + #[case] reply: redis::RedisResult, + entry: Value, + context: SemanticCacheContext, +) { + let recording = recording([reply]); + + assert_eq!( + recording.cache.set_cache("key", entry, &context), + Err(Error::Unavailable) + ); +} + +#[rstest] +#[case::within_threshold(search_hit(&encoded(&entry()), "0.1"), Ok(Some(entry())))] +#[case::at_threshold(search_hit(&encoded(&entry()), "0.2"), Ok(Some(entry())))] +#[case::beyond_threshold(search_hit(&encoded(&entry()), "0.5"), Ok(None))] +#[case::zero_documents(redis::Value::Array(vec![redis::Value::Int(0)]), Ok(None))] +#[case::missing_response( + redis::Value::Array(vec![ + redis::Value::Int(1), + bulk(b"document"), + redis::Value::Array(vec![bulk(b"vector_distance"), bulk(b"0.1")]), + ]), + Err(Error::InvalidEntry) +)] +#[case::unparsable_distance(search_hit(b"not-json", "abc"), Err(Error::InvalidEntry))] +#[case::undecodable_response(search_hit(b"not-json", "0.1"), Err(Error::InvalidEntry))] +fn get_applies_threshold_and_decodes_entry( + #[case] reply: redis::Value, + #[case] expected: Result, Error>, + context: SemanticCacheContext, +) { + let recording = recording([ok(), Ok(reply)]); + + assert_eq!(recording.cache.get_cache("key", &context), expected); +} + +#[rstest] +#[case::hit(context(), Some(search_hit(&encoded(&entry()), "0.1")), Some(entry()), Some(1.0 - 0.1))] +#[case::below_threshold(context(), Some(search_hit(&encoded(&entry()), "0.5")), None, Some(1.0 - 0.5))] +#[case::no_results(context(), Some(redis::Value::Array(vec![redis::Value::Int(0)])), None, Some(0.0))] +#[case::no_prompt(SemanticCacheContext::default(), None, None, Some(0.0))] +#[tokio::test] +async fn lookup_reports_python_semantic_similarity( + #[case] context: SemanticCacheContext, + #[case] reply: Option, + #[case] value: Option, + #[case] similarity: Option, + #[values(false, true)] use_async: bool, +) { + let searched = reply.is_some(); + let recording = recording(reply.map_or_else(Vec::new, |reply| vec![ok(), Ok(reply)])); + + let lookup = if use_async { + recording + .cache + .async_get_cache_with_similarity("key", &context) + .await + } else { + recording.cache.get_cache_with_similarity("key", &context) + }; + + assert_eq!(lookup, Ok(SemanticLookup { value, similarity })); + assert_eq!(recording.text().contains("FT.SEARCH"), searched); +} + +#[rstest] +#[tokio::test] +async fn missing_prompt_does_not_touch_valkey(entry: Value) { + let cache = ValkeySemanticCache::with_connection( + MockRedisConnection::new([]).assert_all_commands_consumed(), + FakeEmbedder::new(&[]), + JsonCodec::new(), + config(), + ); + let context = SemanticCacheContext::default(); + + cache.set_cache("key", entry.clone(), &context).unwrap(); + assert_eq!(cache.get_cache("key", &context).unwrap(), None); + cache + .async_set_cache("key", entry, context.clone()) + .await + .unwrap(); + assert_eq!(cache.async_get_cache("key", &context).await.unwrap(), None); + assert_eq!(cache.get_ttl(&context), None); +} + +#[rstest] +fn with_embedder_shares_index_state_and_connections(entry: Value, context: SemanticCacheContext) { + let recording = recording([ok(), ok(), Ok(search_hit(&encoded(&entry), "0.1"))]); + recording + .cache + .set_cache("key", entry.clone(), &context) + .unwrap(); + + let prepared = recording + .cache + .with_embedder(PreparedEmbedding(vec![0.1, 0.2, 0.3])); + + assert_eq!(prepared.get_cache("key", &context).unwrap(), Some(entry)); + assert_eq!(recording.text().matches("FT.CREATE").count(), 1); +} + +#[rstest] +fn accessors_report_the_config() { + let recording = recording([]); + + assert_eq!(recording.cache.index_name(), "test"); + assert_eq!(recording.cache.similarity_threshold(), 0.8); + assert_eq!(DEFAULT_INDEX_NAME, "litellm_semantic_cache_index"); +} + +#[rstest] +#[tokio::test] +async fn async_set_and_get_use_shared_document_helpers( + entry: Value, + context: SemanticCacheContext, +) { + let recording = recording([ok(), ok(), ok(), Ok(search_hit(&encoded(&entry), "0.1"))]); + let context = SemanticCacheContext { + ttl: Some(Duration::from_millis(1900)), + ..context + }; + + recording + .cache + .async_set_cache("key", entry.clone(), context.clone()) + .await + .unwrap(); + assert_eq!( + recording + .cache + .async_get_cache("key", &context) + .await + .unwrap(), + Some(entry) + ); + + let text = recording.text(); + assert!(text.contains("FT.CREATE")); + assert!(text.contains("HSET")); + assert!(text.contains("EXPIRE")); + assert_eq!( + *recording.calls.lock().unwrap(), + vec![("hello".to_owned(), context.metadata.clone()); 2] + ); +} diff --git a/litellm-rust/crates/cache-valkey-semantic/tests/contract.rs b/litellm-rust/crates/cache-valkey-semantic/tests/contract.rs new file mode 100644 index 00000000000..71081fca92b --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/tests/contract.rs @@ -0,0 +1,62 @@ +//! `overwrite_replaces` does not apply: like Python, every write is a new `:` +//! document, so a second write with the same prompt adds a tie instead of replacing the first. + +mod support; + +use litellm_cache::{JsonCodec, SemanticCacheContext, semantic::PreparedEmbedding}; +use litellm_cache_testing as contract; +use litellm_cache_valkey_semantic::{ + DEFAULT_INDEX_NAME, ValkeySemanticCache, ValkeySemanticConfig, +}; +use rstest::{fixture, rstest}; +use serde_json::{Value, json}; +use support::FakeSearch; + +type Cache = ValkeySemanticCache, FakeSearch>; + +const PREFIX: &str = "contract:"; + +#[fixture] +fn cache() -> Cache { + ValkeySemanticCache::with_connection( + FakeSearch::default(), + PreparedEmbedding(vec![0.6, 0.8]), + JsonCodec::new(), + ValkeySemanticConfig { + similarity_threshold: 0.9, + index_name: DEFAULT_INDEX_NAME.into(), + }, + ) +} + +#[fixture] +fn context() -> SemanticCacheContext { + SemanticCacheContext { + messages: Some(json!([{"role": "user", "content": "contract prompt"}])), + ..Default::default() + } +} + +#[rstest] +#[tokio::test] +async fn hit_and_miss(cache: Cache, context: SemanticCacheContext) { + contract::hit_and_miss(&cache, context, PREFIX, json!({"answer": 42})).await; +} + +#[rstest] +#[tokio::test] +async fn sync_async_equivalence(cache: Cache, context: SemanticCacheContext) { + contract::sync_async_equivalence(&cache, context, PREFIX, json!("first"), json!([2])).await; +} + +#[rstest] +#[tokio::test] +async fn pipeline_writes_every_entry(cache: Cache, context: SemanticCacheContext) { + contract::pipeline_writes_every_entry( + &cache, + context, + PREFIX, + vec![json!("a"), json!(2), json!({"c": true})], + ) + .await; +} diff --git a/litellm-rust/crates/cache-valkey-semantic/tests/support/mod.rs b/litellm-rust/crates/cache-valkey-semantic/tests/support/mod.rs new file mode 100644 index 00000000000..2c6ff06b134 --- /dev/null +++ b/litellm-rust/crates/cache-valkey-semantic/tests/support/mod.rs @@ -0,0 +1,349 @@ +#![allow(dead_code)] + +use std::{ + collections::{BTreeMap, HashMap, VecDeque}, + sync::{Arc, Mutex}, +}; + +use litellm_cache::{Error, semantic::Embedder}; +use serde_json::Value; + +pub type EmbedCalls = Arc)>>>; + +/// Embeds known prompts to fixed vectors, anything else to `[0.1, 0.2, 0.3]`, and records every +/// prompt with its metadata. +pub struct FakeEmbedder { + vectors: HashMap>, + pub calls: EmbedCalls, +} + +impl FakeEmbedder { + pub fn new(vectors: &[(&str, &[f32])]) -> Self { + Self { + vectors: vectors + .iter() + .map(|(prompt, vector)| ((*prompt).to_owned(), vector.to_vec())) + .collect(), + calls: EmbedCalls::default(), + } + } +} + +impl Embedder for FakeEmbedder { + fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { + self.calls + .lock() + .unwrap() + .push((prompt.to_owned(), metadata.cloned())); + Ok(self + .vectors + .get(prompt) + .cloned() + .unwrap_or_else(|| vec![0.1, 0.2, 0.3])) + } + + async fn async_embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { + self.embed(prompt, metadata) + } +} + +struct FakeIndex { + prefix: Vec, + dims: usize, + vector_field: String, +} + +#[derive(Default)] +struct SearchState { + indexes: HashMap, + hashes: BTreeMap, BTreeMap>>, +} + +/// An in-memory valkey-search speaking the `FT.*`, `HSET` and `EXPIRE` subset the semantic cache +/// sends, with exact cosine KNN over the hashes under an index prefix. +#[derive(Clone, Default)] +pub struct FakeSearch { + state: Arc>, +} + +impl FakeSearch { + fn run(&self, args: Vec>) -> redis::RedisResult { + let mut state = self.state.lock().unwrap(); + let text = |index: usize| String::from_utf8_lossy(&args[index]).into_owned(); + match text(0).to_uppercase().as_str() { + "FT.CREATE" => { + let name = text(1); + if state.indexes.contains_key(&name) { + return Err(error("Index already exists")); + } + let position = |token: &str| args.iter().position(|arg| arg == token.as_bytes()); + let prefix = args[position("PREFIX").unwrap() + 2].clone(); + let dims = text(position("DIM").unwrap() + 1).parse().unwrap(); + let vector_field = text(position("VECTOR").unwrap() - 1); + state.indexes.insert( + name, + FakeIndex { + prefix, + dims, + vector_field, + }, + ); + Ok(redis::Value::Okay) + } + "FT.INFO" => { + let index = state + .indexes + .get(&text(1)) + .ok_or_else(|| error("Unknown index name"))?; + Ok(index_info(index)) + } + "FT.DROPINDEX" => { + state.indexes.remove(&text(1)); + Ok(redis::Value::Okay) + } + "HSET" => { + let hash = state.hashes.entry(args[1].clone()).or_default(); + for pair in args[2..].chunks(2) { + hash.insert( + String::from_utf8_lossy(&pair[0]).into_owned(), + pair[1].clone(), + ); + } + Ok(redis::Value::Int(((args.len() - 2) / 2) as i64)) + } + "EXPIRE" => Ok(redis::Value::Int(i64::from( + state.hashes.contains_key(&args[1]), + ))), + "FT.SEARCH" => { + let index = state + .indexes + .get(&text(1)) + .ok_or_else(|| error("no such index"))?; + let query = text(2); + let tag = query_tag(&query); + let params = args.iter().position(|arg| arg == b"PARAMS").unwrap(); + let vector = floats(&args[params + 3]); + let best = state + .hashes + .iter() + .filter(|(key, _)| key.starts_with(&index.prefix)) + .filter(|(_, fields)| { + fields.get("litellm_cache_key").map(Vec::as_slice) == Some(tag.as_bytes()) + }) + .filter_map(|(key, fields)| { + let stored = floats(fields.get(&index.vector_field)?); + (stored.len() == index.dims) + .then(|| (key, fields, 1.0 - cosine(&vector, &stored))) + }) + .min_by(|left, right| left.2.total_cmp(&right.2)); + let Some((key, fields, distance)) = best else { + return Ok(redis::Value::Array(vec![redis::Value::Int(0)])); + }; + let mut reply = fields + .iter() + .filter(|(name, _)| **name != index.vector_field) + .flat_map(|(name, value)| [bulk(name.as_bytes()), bulk(value)]) + .collect::>(); + reply.extend([ + bulk(b"vector_distance"), + bulk(distance.to_string().as_bytes()), + ]); + Ok(redis::Value::Array(vec![ + redis::Value::Int(1), + bulk(key), + redis::Value::Array(reply), + ])) + } + "PING" => Ok(redis::Value::SimpleString("PONG".into())), + _ => Err(error("unsupported command")), + } + } +} + +impl redis::ConnectionLike for FakeSearch { + fn req_packed_command(&mut self, command: &[u8]) -> redis::RedisResult { + let mut commands = parse_commands(command); + self.run(commands.remove(0)) + } + + fn req_packed_commands( + &mut self, + commands: &[u8], + offset: usize, + count: usize, + ) -> redis::RedisResult> { + let replies = parse_commands(commands) + .into_iter() + .map(|args| self.run(args)) + .collect::>>()?; + Ok(replies.into_iter().skip(offset).take(count).collect()) + } + + fn get_db(&self) -> i64 { + 0 + } + + fn check_connection(&mut self) -> bool { + true + } + + fn is_open(&self) -> bool { + true + } +} + +fn error(message: &'static str) -> redis::RedisError { + redis::RedisError::from((redis::ErrorKind::Extension, message)) +} + +fn bulk(bytes: &[u8]) -> redis::Value { + redis::Value::BulkString(bytes.to_vec()) +} + +fn index_info(index: &FakeIndex) -> redis::Value { + redis::Value::Array(vec![ + bulk(b"index_name"), + bulk(b"fake"), + bulk(b"attributes"), + redis::Value::Array(vec![ + redis::Value::Array(vec![ + bulk(b"identifier"), + bulk(b"litellm_cache_key"), + bulk(b"type"), + bulk(b"TAG"), + ]), + redis::Value::Array(vec![ + bulk(b"identifier"), + bulk(index.vector_field.as_bytes()), + bulk(b"type"), + bulk(b"VECTOR"), + bulk(b"index"), + redis::Value::Array(vec![ + bulk(b"dimensions"), + redis::Value::Int(index.dims as i64), + ]), + ]), + ]), + ]) +} + +/// The tag inside `@litellm_cache_key:{...}`, with query escapes removed. +fn query_tag(query: &str) -> String { + let start = query.find("@litellm_cache_key:{").unwrap() + "@litellm_cache_key:{".len(); + let mut tag = String::new(); + let mut characters = query[start..].chars(); + while let Some(character) = characters.next() { + match character { + '\\' => tag.extend(characters.next()), + '}' => break, + character => tag.push(character), + } + } + tag +} + +fn floats(bytes: &[u8]) -> Vec { + bytes + .as_chunks::<4>() + .0 + .iter() + .map(|chunk| f32::from_le_bytes(*chunk)) + .collect() +} + +fn cosine(left: &[f32], right: &[f32]) -> f64 { + let dot = left + .iter() + .zip(right) + .map(|(left, right)| f64::from(*left) * f64::from(*right)) + .sum::(); + let norm = |vector: &[f32]| { + vector + .iter() + .map(|value| f64::from(*value).powi(2)) + .sum::() + .sqrt() + }; + dot / (norm(left) * norm(right)) +} + +/// Splits a packed RESP request into each command's arguments. +fn parse_commands(mut bytes: &[u8]) -> Vec>> { + let line = |bytes: &mut &[u8]| { + let end = bytes + .windows(2) + .position(|window| window == b"\r\n") + .unwrap(); + let text = String::from_utf8(bytes[1..end].to_vec()).unwrap(); + *bytes = &bytes[end + 2..]; + text.parse::().unwrap() + }; + let mut commands = Vec::new(); + while !bytes.is_empty() { + let count = line(&mut bytes); + let mut args = Vec::with_capacity(count); + for _ in 0..count { + let length = line(&mut bytes); + args.push(bytes[..length].to_vec()); + bytes = &bytes[length + 2..]; + } + commands.push(args); + } + commands +} + +/// Records every packed request and answers from a script, `OK` once the script runs out. +pub struct RecordingConnection { + requests: Arc>>>, + replies: Mutex>>, +} + +impl RecordingConnection { + pub fn new(replies: impl IntoIterator>) -> Self { + Self { + requests: Arc::default(), + replies: Mutex::new(replies.into_iter().collect()), + } + } + + pub fn requests(&self) -> Arc>>> { + Arc::clone(&self.requests) + } + + fn reply(&self) -> redis::RedisResult { + self.replies + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Ok(redis::Value::SimpleString("OK".into()))) + } +} + +impl redis::ConnectionLike for RecordingConnection { + fn req_packed_command(&mut self, command: &[u8]) -> redis::RedisResult { + self.requests.lock().unwrap().push(command.to_vec()); + self.reply() + } + + fn req_packed_commands( + &mut self, + command: &[u8], + _offset: usize, + count: usize, + ) -> redis::RedisResult> { + self.requests.lock().unwrap().push(command.to_vec()); + (0..count).map(|_| self.reply()).collect() + } + + fn get_db(&self) -> i64 { + 0 + } + + fn check_connection(&mut self) -> bool { + true + } + + fn is_open(&self) -> bool { + true + } +} diff --git a/litellm-rust/crates/cache/Cargo.toml b/litellm-rust/crates/cache/Cargo.toml index 0c504ab727a..f18dbd9cb26 100644 --- a/litellm-rust/crates/cache/Cargo.toml +++ b/litellm-rust/crates/cache/Cargo.toml @@ -7,7 +7,7 @@ repository.workspace = true [dependencies] serde.workspace = true -serde_json.workspace = true +serde_json = { workspace = true, features = ["preserve_order"] } thiserror.workspace = true [dev-dependencies] diff --git a/litellm-rust/crates/cache/src/base_cache.rs b/litellm-rust/crates/cache/src/base_cache.rs index 5c10e7fd5c3..506d944b0e8 100644 --- a/litellm-rust/crates/cache/src/base_cache.rs +++ b/litellm-rust/crates/cache/src/base_cache.rs @@ -122,36 +122,4 @@ pub trait BaseCache: Send + Sync { ) -> impl Future> + Send { self.async_set_cache(key, value, context) } - - fn disconnect(&self) -> impl Future> + Send; - - fn test_connection(&self) -> impl Future> + Send; -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use serde_json::json; - - use super::{CacheContext, SemanticCacheContext}; - - #[test] - fn semantic_context_with_ttl_only_replaces_ttl() { - let context = SemanticCacheContext { - input: Some(json!({"input": "hello"})), - messages: Some(json!([{"role": "user", "content": "hello"}])), - metadata: Some(json!({"tenant": "team"})), - scope: Some("scope".into()), - ttl: Some(Duration::from_secs(10)), - }; - - let updated = context.with_ttl(Some(Duration::from_secs(20))); - - assert_eq!(updated.ttl, Some(Duration::from_secs(20))); - assert_eq!(updated.input, context.input); - assert_eq!(updated.messages, context.messages); - assert_eq!(updated.metadata, context.metadata); - assert_eq!(updated.scope, context.scope); - } } diff --git a/litellm-rust/crates/cache/src/cache_type.rs b/litellm-rust/crates/cache/src/cache_type.rs index f0a97c04fd5..22d8c8c7cb5 100644 --- a/litellm-rust/crates/cache/src/cache_type.rs +++ b/litellm-rust/crates/cache/src/cache_type.rs @@ -55,31 +55,3 @@ impl CacheType { .find(|cache_type| cache_type.as_python_name() == value) } } - -#[cfg(test)] -mod tests { - use super::CacheType; - - #[test] - fn every_python_cache_type_has_one_round_trip_identity() { - let names = CacheType::ALL.map(CacheType::as_python_name); - assert_eq!( - names, - [ - "local", - "redis", - "redis-semantic", - "valkey-semantic", - "s3", - "disk", - "qdrant-semantic", - "azure-blob", - "gcs", - ] - ); - assert_eq!( - names.map(CacheType::from_python_name), - CacheType::ALL.map(Some) - ); - } -} diff --git a/litellm-rust/crates/cache/src/capabilities.rs b/litellm-rust/crates/cache/src/capabilities.rs index f7307e5c7bd..ac9d8fc8764 100644 --- a/litellm-rust/crates/cache/src/capabilities.rs +++ b/litellm-rust/crates/cache/src/capabilities.rs @@ -1,6 +1,6 @@ use std::{future::Future, time::Duration}; -use crate::{BaseCache, BatchEntry, Error}; +use crate::{BaseCache, BatchEntry, CacheConnectionResult, CacheContext, Error}; #[derive(Clone, Debug, PartialEq)] pub struct IncrementOperation { @@ -9,6 +9,35 @@ pub struct IncrementOperation { pub ttl: Option, } +#[derive(Clone, Debug, PartialEq)] +pub struct PushOperation { + pub key: String, + pub values: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PopOperation { + pub key: String, + pub count: Option, +} + +/// `disconnect`, for backends whose Python class releases connections or clients. +pub trait DisconnectCache: BaseCache { + fn disconnect(&self) -> impl Future> + Send; +} + +/// `test_connection`, for backends whose Python class overrides the base `NotImplementedError`. +pub trait ConnectionCache: BaseCache { + fn test_connection(&self) -> impl Future> + Send; +} + +/// `sync_ping` and `ping`. +pub trait PingCache: BaseCache { + fn sync_ping(&self) -> Result; + + fn ping(&self) -> impl Future> + Send; +} + pub trait BatchCache: BaseCache { fn batch_get_cache( &self, @@ -45,6 +74,14 @@ pub trait BatchCache: BaseCache { } } +/// `async_set_cache_pipeline_with_ttls`: one pipeline where every entry carries its own TTL. +pub trait TtlPipelineCache: BaseCache { + fn async_set_cache_pipeline_with_ttls( + &self, + entries: Vec<(String, Self::Value, Option)>, + ) -> impl Future> + Send; +} + pub trait DeleteCache: BaseCache { fn delete_cache(&self, key: &str) -> Result<(), Error>; @@ -53,6 +90,14 @@ pub trait DeleteCache: BaseCache { } } +/// `delete_cache_keys`: one round trip that reports how many keys existed. +pub trait BulkDeleteCache: DeleteCache { + fn delete_cache_keys( + &self, + keys: Vec, + ) -> impl Future> + Send; +} + pub trait FlushCache: BaseCache { fn flush_cache(&self) -> Result<(), Error>; @@ -61,18 +106,79 @@ pub trait FlushCache: BaseCache { } } -pub trait CounterCache: BaseCache { +/// `flushall`: drops every key on the server, ignoring any namespace. +pub trait FlushAllCache: FlushCache { + fn flushall(&self) -> Result<(), Error>; +} + +/// Numeric counters. Counters are independent of `Value`, so a response-valued backend can +/// expose them, the way one Python `RedisCache` serves both. +pub trait CounterCache: BaseCache { fn increment_cache(&self, key: &str, amount: f64, context: Self::Context) -> Result; + /// `refresh_ttl` re-arms the TTL on every write instead of only when the key is new; + /// backends without expiring counters ignore it, as Python's `**kwargs` does. fn async_increment( &self, key: &str, amount: f64, context: Self::Context, + _refresh_ttl: bool, ) -> impl Future> + Send { async move { self.increment_cache(key, amount, context) } } + + /// `async_increment_pipeline`, one result per operation in order. The default increments + /// one key at a time, as the in-memory cache does. + fn async_increment_pipeline( + &self, + operations: Vec, + ) -> impl Future, Error>> + Send + where + Self::Context: Default, + { + async move { + let mut results = Vec::with_capacity(operations.len()); + for operation in operations { + let context = Self::Context::default().with_ttl(operation.ttl); + results.push( + self.async_increment(&operation.key, operation.amount, context, false) + .await?, + ); + } + Ok(results) + } + } +} + +/// `batch_get_counts` and `async_batch_get_counts`: counter values read in one round trip. +pub trait CountReadCache: CounterCache { + fn batch_get_counts(&self, keys: &[String]) -> Result>, Error>; + + fn async_batch_get_counts( + &self, + keys: Vec, + ) -> impl Future>, Error>> + Send; +} + +/// `increment_with_floor`, `async_increment_with_floor`, and `async_set_max`. +pub trait BoundedCounterCache: CounterCache { + fn increment_with_floor(&self, key: &str, amount: i64, ttl: Duration) -> Result; + + fn async_increment_with_floor( + &self, + key: &str, + amount: i64, + ttl: Duration, + ) -> impl Future> + Send; + + fn async_set_max( + &self, + key: &str, + value: f64, + ttl: Option, + ) -> impl Future> + Send; } pub trait ClaimCache: BaseCache @@ -105,6 +211,17 @@ pub trait TtlCache: BaseCache { ) -> impl Future, Error>> + Send; } +pub trait RefreshTtlCache: TtlCache { + /// `async_refresh_ttl`: re-arms an existing key without touching its value. `ttl` falls + /// back to the backend default, and the result is `false` when the key is absent or + /// neither TTL is set. + fn async_refresh_ttl( + &self, + key: &str, + ttl: Option, + ) -> impl Future> + Send; +} + pub trait SetCache: BaseCache { type SetValue: Clone + Send + Sync + 'static; type SetResult: Send + Sync + 'static; @@ -127,11 +244,30 @@ pub trait QueueCache: BaseCache { values: Vec, ) -> impl Future> + Send; + /// `async_rpush_and_trim`: pushes, then keeps only the newest `max_len` entries, atomically. + /// Returns the list length right after the push, before the trim. + fn async_rpush_and_trim( + &self, + key: &str, + values: Vec, + max_len: usize, + ) -> impl Future> + Send; + + fn async_rpush_pipeline( + &self, + operations: Vec>, + ) -> impl Future, Error>> + Send; + fn async_lpop( &self, key: &str, count: Option, ) -> impl Future> + Send; + + fn async_lpop_pipeline( + &self, + operations: Vec, + ) -> impl Future, Error>> + Send; } pub trait ScanCache: BaseCache { diff --git a/litellm-rust/crates/cache/src/dual.rs b/litellm-rust/crates/cache/src/dual.rs index d68d4b2b69f..c4bc953239d 100644 --- a/litellm-rust/crates/cache/src/dual.rs +++ b/litellm-rust/crates/cache/src/dual.rs @@ -1,8 +1,8 @@ use std::{sync::Arc, time::Duration}; use crate::{ - BaseCache, BatchCache, BatchEntry, CacheConnectionResult, CacheContext, ClaimCache, - CounterCache, DeleteCache, Error, FlushCache, + BaseCache, BatchCache, BatchEntry, BulkDeleteCache, CacheContext, ClaimCache, CounterCache, + DeleteCache, Error, FlushCache, IncrementOperation, SetCache, TtlCache, }; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -33,8 +33,12 @@ pub struct DualCache { write_policy: WritePolicy, remote_failure_policy: RemoteFailurePolicy, promotion_ttl: Option, + delete_batch_size: usize, } +/// `DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE`. +pub const DEFAULT_DELETE_BATCH_SIZE: usize = 1000; + impl DualCache { pub fn new(l1: Arc, l2: Arc) -> Self { Self { @@ -44,6 +48,14 @@ impl DualCache { write_policy: WritePolicy::default(), remote_failure_policy: RemoteFailurePolicy::default(), promotion_ttl: None, + delete_batch_size: DEFAULT_DELETE_BATCH_SIZE, + } + } + + pub fn with_delete_batch_size(self, delete_batch_size: usize) -> Self { + Self { + delete_batch_size, + ..self } } @@ -217,15 +229,6 @@ where } self.l1.async_set_cache_pipeline(entries, context).await } - - async fn disconnect(&self) -> Result<(), Error> { - self.l2.disconnect().await?; - self.l1.disconnect().await - } - - async fn test_connection(&self) -> Result { - self.l2.test_connection().await - } } impl BatchCache for DualCache @@ -320,26 +323,146 @@ where } } +impl DualCache +where + C: CacheContext, + L1: BaseCache, +{ + /// Python's `local_only=True` increment: the local tier alone, read then written back. + fn increment_local(&self, key: &str, amount: f64, context: &C) -> Result { + let value = self.l1.get_cache(key, context)?.unwrap_or(0.0) + amount; + self.l1.set_cache(key, value, context)?; + Ok(value) + } +} + impl CounterCache for DualCache where C: CacheContext, L1: BaseCache, - L2: CounterCache, + L2: CounterCache, { fn increment_cache(&self, key: &str, amount: f64, context: C) -> Result { + if !self.writes_remote() { + return self.increment_local(key, amount, &context); + } let value = self.l2.increment_cache(key, amount, context.clone())?; self.l1.set_cache(key, value, &context)?; Ok(value) } - async fn async_increment(&self, key: &str, amount: f64, context: C) -> Result { + async fn async_increment( + &self, + key: &str, + amount: f64, + context: C, + refresh_ttl: bool, + ) -> Result { + if !self.writes_remote() { + return self.increment_local(key, amount, &context); + } let value = self .l2 - .async_increment(key, amount, context.clone()) + .async_increment(key, amount, context.clone(), refresh_ttl) .await?; self.l1.async_set_cache(key, value, context).await?; Ok(value) } + + /// `async_increment_cache_pipeline`, L2-first like single increments: the local tier takes + /// each remote result. + async fn async_increment_pipeline( + &self, + operations: Vec, + ) -> Result, Error> + where + C: Default, + { + if !self.writes_remote() { + return operations + .iter() + .map(|operation| { + let context = C::default().with_ttl(operation.ttl); + self.increment_local(&operation.key, operation.amount, &context) + }) + .collect(); + } + let values = self.l2.async_increment_pipeline(operations.clone()).await?; + if values.len() != operations.len() { + return Err(Error::Unavailable); + } + for (operation, value) in operations.iter().zip(&values) { + self.l1 + .async_set_cache(&operation.key, *value, C::default().with_ttl(operation.ttl)) + .await?; + } + Ok(values) + } +} + +/// `async_set_cache_sadd`: local set first, then the remote one unless writes stay local. +impl SetCache for DualCache +where + V: Clone + Send + Sync + 'static, + C: CacheContext, + S: Clone + Send + Sync + 'static, + L1: SetCache, + L2: SetCache, +{ + type SetValue = S; + type SetResult = (); + + async fn async_set_cache_sadd( + &self, + key: &str, + values: Vec, + ttl: Option, + ) -> Result<(), Error> { + self.l1 + .async_set_cache_sadd(key, values.clone(), ttl) + .await?; + if self.writes_remote() { + self.l2.async_set_cache_sadd(key, values, ttl).await?; + } + Ok(()) + } +} + +/// `async_delete_cache_keys`: every key leaves the local tier, then the remote tier in chunks +/// of `DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE`, since Redis takes a chunk as one command. +impl BulkDeleteCache for DualCache +where + V: Clone + Send + Sync + 'static, + C: CacheContext, + L1: DeleteCache, + L2: BulkDeleteCache, +{ + async fn delete_cache_keys(&self, keys: Vec) -> Result { + for key in &keys { + self.l1.delete_cache(key)?; + } + let mut deleted = 0; + for chunk in keys.chunks(self.delete_batch_size.max(1)) { + deleted += self.l2.delete_cache_keys(chunk.to_vec()).await?; + } + Ok(deleted) + } +} + +/// `async_get_ttl`: the local TTL, or the remote one when the local tier has none. +impl TtlCache for DualCache +where + V: Clone + Send + Sync + 'static, + C: CacheContext, + L1: TtlCache, + L2: TtlCache, +{ + async fn async_get_ttl(&self, key: &str) -> Result, Error> { + match self.l1.async_get_ttl(key).await? { + Some(ttl) => Ok(Some(ttl)), + None => self.l2.async_get_ttl(key).await, + } + } } impl ClaimCache for DualCache diff --git a/litellm-rust/crates/cache/src/lib.rs b/litellm-rust/crates/cache/src/lib.rs index 8364c635e3a..55d8a5fa28b 100644 --- a/litellm-rust/crates/cache/src/lib.rs +++ b/litellm-rust/crates/cache/src/lib.rs @@ -5,6 +5,7 @@ mod capabilities; mod codec; mod dual; mod error; +pub mod semantic; pub use base_cache::{ BaseCache, BatchEntry, CacheConnectionResult, CacheConnectionStatus, CacheContext, @@ -13,9 +14,13 @@ pub use base_cache::{ pub use cache_type::CacheType; pub use caching::{Cache, CacheBackend, get_cache, set_cache}; pub use capabilities::{ - BatchCache, CacheScript, ClaimCache, ClientInfoCache, CounterCache, DeleteCache, FlushCache, - IncrementOperation, QueueCache, ScanCache, ScriptCache, SetCache, TtlCache, + BatchCache, BoundedCounterCache, BulkDeleteCache, CacheScript, ClaimCache, ClientInfoCache, + ConnectionCache, CountReadCache, CounterCache, DeleteCache, DisconnectCache, FlushAllCache, + FlushCache, IncrementOperation, PingCache, PopOperation, PushOperation, QueueCache, + RefreshTtlCache, ScanCache, ScriptCache, SetCache, TtlCache, TtlPipelineCache, }; pub use codec::{CacheCodec, JsonCodec}; -pub use dual::{DualCache, ReadPolicy, RemoteFailurePolicy, WritePolicy}; +pub use dual::{ + DEFAULT_DELETE_BATCH_SIZE, DualCache, ReadPolicy, RemoteFailurePolicy, WritePolicy, +}; pub use error::Error; diff --git a/litellm-rust/crates/cache/src/semantic.rs b/litellm-rust/crates/cache/src/semantic.rs new file mode 100644 index 00000000000..c88706a213b --- /dev/null +++ b/litellm-rust/crates/cache/src/semantic.rs @@ -0,0 +1,274 @@ +//! The embedding and prompt contract every semantic backend shares. +//! +//! Python's semantic caches all read their prompt through `get_str_from_messages`, and +//! `RedisSemanticCache._get_prompt_from_kwargs` (inherited by Valkey) adds Responses API +//! `input`. Qdrant reads messages only. Each backend picks one of the two extractors here. + +use std::{future::Future, io}; + +use serde::Serialize; +use serde_json::{ + Value, + ser::{CharEscape, Formatter, Serializer}, +}; + +use crate::{BaseCache, Error, SemanticCacheContext}; + +/// Turns a prompt into the vector a semantic backend stores and searches with. +/// +/// `metadata` is the request metadata, which a host embedder may route on. Hosts that can only +/// embed asynchronously keep the default `embed`; backends that serve sync calls through a +/// runtime then block on `async_embed` instead. +pub trait Embedder: Send + Sync + 'static { + fn embed(&self, _prompt: &str, _metadata: Option<&Value>) -> Result, Error> { + Err(Error::UnsupportedOperation) + } + + fn async_embed( + &self, + prompt: &str, + metadata: Option<&Value>, + ) -> impl Future, Error>> + Send; +} + +/// One semantic read: the cached value, if any, and the similarity Python's backend writes to +/// `metadata["semantic-similarity"]`. `similarity` is `None` when the backend reports none. +#[derive(Clone, Debug, PartialEq)] +pub struct SemanticLookup { + pub value: Option, + pub similarity: Option, +} + +impl SemanticLookup { + /// A read that found nothing, with the similarity Python records for it. + pub fn miss(similarity: Option) -> Self { + Self { + value: None, + similarity, + } + } +} + +/// A semantic backend's read that also reports the similarity of the closest cached prompt, +/// the value Python stamps onto the request metadata as `semantic-similarity`. +pub trait SemanticCache: BaseCache { + fn get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> Result, Error>; + + fn async_get_cache_with_similarity( + &self, + key: &str, + context: &Self::Context, + ) -> impl Future, Error>> + Send; +} + +/// An embedding computed ahead of time, for callers that already hold the vector. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedEmbedding(pub Vec); + +impl Embedder for PreparedEmbedding { + fn embed(&self, _prompt: &str, _metadata: Option<&Value>) -> Result, Error> { + Ok(self.0.clone()) + } + + async fn async_embed( + &self, + _prompt: &str, + _metadata: Option<&Value>, + ) -> Result, Error> { + Ok(self.0.clone()) + } +} + +/// `get_str_from_messages`: every message's text content followed by its search results. +pub fn str_from_messages(messages: &[Value]) -> String { + let mut text = String::new(); + for message in messages.iter().filter_map(Value::as_object) { + match message.get("content") { + Some(Value::String(content)) => text.push_str(content), + Some(Value::Array(parts)) => { + for part in parts { + if let Some(part_text) = part.get("text").and_then(Value::as_str) { + text.push_str(part_text); + } + } + } + _ => {} + } + push_search_results_text(&mut text, message.get("search_results")); + } + text +} + +/// The messages prompt Qdrant embeds: `None` when the request carries no messages. +pub fn prompt_from_messages(context: &SemanticCacheContext) -> Option { + let messages = context.messages.as_ref()?.as_array()?; + (!messages.is_empty()).then(|| str_from_messages(messages)) +} + +/// `RedisSemanticCache._get_prompt_from_kwargs`: chat messages first, then the text parts of a +/// Responses API `input`. `None` when neither yields a prompt. +pub fn prompt_from_context(context: &SemanticCacheContext) -> Option { + if let Some(messages) = context.messages.as_ref().and_then(Value::as_array) + && !messages.is_empty() + { + return Some(str_from_messages(messages)); + } + let input = context.input.as_ref()?; + let mut parts = Vec::new(); + collect_input_text(input, &mut parts); + let prompt = python_strip(&parts.join("\n")).to_owned(); + (!prompt.is_empty()).then_some(prompt) +} + +/// `extract_search_results_text`. +fn push_search_results_text(text: &mut String, search_results: Option<&Value>) { + let Some(Value::Array(results)) = search_results else { + return; + }; + for result in results.iter().filter_map(Value::as_object) { + for key in ["source", "title"] { + if let Some(value) = result.get(key).and_then(Value::as_str) { + text.push_str(value); + } + } + if let Some(Value::Array(content)) = result.get("content") { + for block in content.iter().filter_map(Value::as_object) { + if let Some(value) = block.get("text").and_then(Value::as_str) { + text.push_str(value); + } + } + } + if let Some(citations) = result.get("citations").filter(|value| !value.is_null()) { + text.push_str(&compact_json(citations)); + } + } +} + +fn collect_input_text(value: &Value, parts: &mut Vec) { + match value { + Value::String(text) => { + push_trimmed(text, parts); + } + Value::Array(items) => { + for item in items { + collect_input_text(item, parts); + } + } + Value::Object(map) => { + if let Some(content) = map.get("content").filter(|content| !content.is_null()) { + collect_input_text(content, parts); + return; + } + for key in ["text", "output", "input_text", "output_text"] { + if let Some(Value::String(text)) = map.get(key) + && push_trimmed(text, parts) + { + return; + } + } + } + _ => {} + } +} + +/// Pushes `text` stripped as Python's `str.strip` does, reporting whether anything was left. +fn push_trimmed(text: &str, parts: &mut Vec) -> bool { + let trimmed = python_strip(text); + if trimmed.is_empty() { + return false; + } + parts.push(trimmed.to_owned()); + true +} + +/// `str.strip()`: Python's whitespace also covers the ASCII information separators. +fn python_strip(text: &str) -> &str { + text.trim_matches(|character: char| { + character.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&character) + }) +} + +/// `json.dumps(value, separators=(",", ":"))`: compact, key insertion order, `ensure_ascii`. +fn compact_json(value: &Value) -> String { + let mut output = Vec::new(); + // Serializing a `Value` into memory cannot fail. + let _ = value.serialize(&mut Serializer::with_formatter(&mut output, AsciiFormatter)); + String::from_utf8(output).unwrap_or_default() +} + +struct AsciiFormatter; + +impl Formatter for AsciiFormatter { + fn write_string_fragment(&mut self, writer: &mut W, fragment: &str) -> io::Result<()> + where + W: ?Sized + io::Write, + { + let mut start = 0; + for (index, character) in fragment.char_indices() { + if character.is_ascii() && character != '\u{7f}' { + continue; + } + writer.write_all(&fragment.as_bytes()[start..index])?; + let mut units = [0; 2]; + for unit in character.encode_utf16(&mut units) { + write!(writer, "\\u{unit:04x}")?; + } + start = index + character.len_utf8(); + } + writer.write_all(&fragment.as_bytes()[start..]) + } + + fn write_f64(&mut self, writer: &mut W, value: f64) -> io::Result<()> + where + W: ?Sized + io::Write, + { + writer.write_all(python_float_repr(value).as_bytes()) + } + + fn write_char_escape(&mut self, writer: &mut W, escape: CharEscape) -> io::Result<()> + where + W: ?Sized + io::Write, + { + match escape { + CharEscape::AsciiControl(byte) => write!(writer, "\\u{byte:04x}"), + escape => serde_json::ser::CompactFormatter.write_char_escape(writer, escape), + } + } +} + +/// `repr(float)`: the shortest round-trip digits, positional between `1e-4` and `1e16`, and +/// otherwise scientific with a signed exponent of at least two digits. +fn python_float_repr(value: f64) -> String { + // `{:e}` yields the shortest round-trip digits, e.g. `1.5e-7`. + let scientific = format!("{value:e}"); + let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0")); + let exponent: i32 = exponent.parse().unwrap_or(0); + let (sign, mantissa) = mantissa + .strip_prefix('-') + .map_or(("", mantissa), |rest| ("-", rest)); + let digits = mantissa.replace('.', ""); + if !(-4..16).contains(&exponent) { + let fraction = &digits[1..]; + let mantissa = if fraction.is_empty() { + digits[..1].to_owned() + } else { + format!("{}.{fraction}", &digits[..1]) + }; + let exponent_sign = if exponent < 0 { '-' } else { '+' }; + return format!("{sign}{mantissa}e{exponent_sign}{:02}", exponent.abs()); + } + let point = exponent + 1; + let positional = if point <= 0 { + format!("0.{}{digits}", "0".repeat(point.unsigned_abs() as usize)) + } else if point as usize >= digits.len() { + format!("{digits}{}.0", "0".repeat(point as usize - digits.len())) + } else { + let (whole, fraction) = digits.split_at(point as usize); + format!("{whole}.{fraction}") + }; + format!("{sign}{positional}") +} diff --git a/litellm-rust/crates/cache/tests/cache_type.rs b/litellm-rust/crates/cache/tests/cache_type.rs new file mode 100644 index 00000000000..24aaba8e5fd --- /dev/null +++ b/litellm-rust/crates/cache/tests/cache_type.rs @@ -0,0 +1,56 @@ +use litellm_cache::CacheType; +use rstest::rstest; + +#[rstest] +#[case(CacheType::Local, "local")] +#[case(CacheType::Redis, "redis")] +#[case(CacheType::RedisSemantic, "redis-semantic")] +#[case(CacheType::ValkeySemantic, "valkey-semantic")] +#[case(CacheType::S3, "s3")] +#[case(CacheType::Disk, "disk")] +#[case(CacheType::QdrantSemantic, "qdrant-semantic")] +#[case(CacheType::AzureBlob, "azure-blob")] +#[case(CacheType::Gcs, "gcs")] +fn every_python_cache_type_has_one_round_trip_identity( + #[case] cache_type: CacheType, + #[case] name: &str, +) { + assert_eq!(cache_type.as_python_name(), name); + assert_eq!(CacheType::from_python_name(name), Some(cache_type)); + assert_eq!( + serde_json::to_value(cache_type).unwrap(), + serde_json::Value::from(name) + ); + assert_eq!( + CacheType::ALL + .iter() + .filter(|candidate| candidate.as_python_name() == name) + .count(), + 1 + ); +} + +#[rstest] +fn python_cache_types_are_listed_in_python_order() { + assert_eq!( + CacheType::ALL.map(CacheType::as_python_name), + [ + "local", + "redis", + "redis-semantic", + "valkey-semantic", + "s3", + "disk", + "qdrant-semantic", + "azure-blob", + "gcs", + ] + ); +} + +#[rstest] +#[case::unknown("memcached")] +#[case::case_sensitive("Redis")] +fn unknown_python_names_have_no_cache_type(#[case] name: &str) { + assert_eq!(CacheType::from_python_name(name), None); +} diff --git a/litellm-rust/crates/cache/tests/caching.rs b/litellm-rust/crates/cache/tests/caching.rs index 36307ac9b33..baf968f4659 100644 --- a/litellm-rust/crates/cache/tests/caching.rs +++ b/litellm-rust/crates/cache/tests/caching.rs @@ -1,15 +1,25 @@ use std::{sync::Mutex, time::Duration}; use litellm_cache::{ - BaseCache, CacheConnectionResult, CacheContext, Error, ExactCacheContext, SemanticCacheContext, - get_cache, + BaseCache, CacheContext, CounterCache, Error, ExactCacheContext, IncrementOperation, + SemanticCacheContext, get_cache, }; +use rstest::{fixture, rstest}; +use serde_json::json; struct TestCache { default_ttl: Duration, writes: Mutex>, } +#[fixture] +fn cache() -> TestCache { + TestCache { + default_ttl: Duration::from_secs(60), + writes: Mutex::default(), + } +} + #[derive(Clone)] struct SemanticContext { ttl: Option, @@ -46,14 +56,6 @@ impl BaseCache for SemanticCache { fn get_cache(&self, _: &str, context: &Self::Context) -> Result, Error> { Ok((context.query == "matching prompt").then(|| "semantic hit".into())) } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - unreachable!() - } } impl BaseCache for TestCache { @@ -87,70 +89,124 @@ impl BaseCache for TestCache { fn get_cache(&self, _: &str, _: &ExactCacheContext) -> Result, Error> { Ok(None) } +} - async fn disconnect(&self) -> Result<(), Error> { +/// Records every `async_increment` so the default pipeline's calls are observable. +#[derive(Default)] +struct RecordingCounter { + increments: Mutex>, + total: Mutex, +} + +impl BaseCache for RecordingCounter { + type Value = f64; + type Context = ExactCacheContext; + + fn get_ttl(&self, context: &Self::Context) -> Option { + context.ttl + } + + fn set_cache(&self, _: &str, _: f64, _: &ExactCacheContext) -> Result<(), Error> { Ok(()) } - async fn test_connection(&self) -> Result { - unreachable!() + fn get_cache(&self, _: &str, _: &ExactCacheContext) -> Result, Error> { + Ok(None) } } -#[test] -fn ttl_uses_default_and_allows_per_call_override() { - let cache = TestCache { - default_ttl: Duration::from_secs(60), - writes: Mutex::default(), - }; - assert_eq!( - cache.get_ttl(&ExactCacheContext::default()), - Some(Duration::from_secs(60)) - ); +impl CounterCache for RecordingCounter { + fn increment_cache(&self, key: &str, amount: f64, _: ExactCacheContext) -> Result { + if key == "unavailable" { + return Err(Error::Unavailable); + } + let mut total = self.total.lock().unwrap(); + *total += amount; + Ok(*total) + } + + async fn async_increment( + &self, + key: &str, + amount: f64, + context: ExactCacheContext, + refresh_ttl: bool, + ) -> Result { + self.increments + .lock() + .unwrap() + .push((key.into(), amount, context.clone(), refresh_ttl)); + self.increment_cache(key, amount, context) + } +} + +fn operation(key: &str, amount: f64, ttl: Option) -> IncrementOperation { + IncrementOperation { + key: key.into(), + amount, + ttl: ttl.map(Duration::from_secs), + } +} + +#[rstest] +#[case::default_ttl(None, Some(60))] +#[case::per_call_override(Some(5), Some(5))] +fn ttl_uses_default_and_allows_per_call_override( + cache: TestCache, + #[case] ttl: Option, + #[case] expected: Option, +) { assert_eq!( cache.get_ttl(&ExactCacheContext { - ttl: Some(Duration::from_secs(5)), + ttl: ttl.map(Duration::from_secs), }), - Some(Duration::from_secs(5)) + expected.map(Duration::from_secs) ); } -#[test] -fn associated_context_preserves_backend_specific_lookup_inputs() { +#[rstest] +#[case::matching("matching prompt", Some("semantic hit"))] +#[case::other("other prompt", None)] +fn associated_context_preserves_backend_specific_lookup_inputs( + #[case] query: &str, + #[case] expected: Option<&str>, +) { let context = SemanticContext { ttl: None, - query: "matching prompt".into(), + query: query.into(), }; assert_eq!( get_cache(&SemanticCache, "shared-key", &context).unwrap(), - Some("semantic hit".into()) + expected.map(String::from) ); } -#[test] -fn semantic_context_with_ttl_preserves_lookup_inputs() { +#[rstest] +#[case::set(None, Some(30))] +#[case::replaced(Some(10), Some(20))] +#[case::cleared(Some(10), None)] +fn semantic_context_with_ttl_only_replaces_ttl( + #[case] initial: Option, + #[case] updated: Option, +) { let context = SemanticCacheContext { - input: Some(serde_json::json!("text")), - messages: Some(serde_json::json!([{"role": "user", "content": "hi"}])), - metadata: Some(serde_json::json!({"key": "value"})), + input: Some(json!({"input": "hello"})), + messages: Some(json!([{"role": "user", "content": "hello"}])), + metadata: Some(json!({"tenant": "team"})), scope: Some("scope".into()), - ttl: None, + ttl: initial.map(Duration::from_secs), }; - let updated = context.with_ttl(Some(Duration::from_secs(30))); - assert_eq!(updated.ttl(), Some(Duration::from_secs(30))); - assert_eq!(updated.input, context.input); - assert_eq!(updated.messages, context.messages); - assert_eq!(updated.metadata, context.metadata); - assert_eq!(updated.scope, context.scope); - assert_eq!(context.with_ttl(None).ttl(), None); + let result = context.with_ttl(updated.map(Duration::from_secs)); + assert_eq!(result.ttl(), updated.map(Duration::from_secs)); + assert_eq!(result.input, context.input); + assert_eq!(result.messages, context.messages); + assert_eq!(result.metadata, context.metadata); + assert_eq!(result.scope, context.scope); } +#[rstest] #[tokio::test] -async fn default_batch_operations_use_async_writes_and_stop_on_failure() { - let cache = TestCache { - default_ttl: Duration::from_secs(60), - writes: Mutex::default(), - }; +async fn default_batch_operations_use_async_writes_and_stop_on_failure(cache: TestCache) { let entry = String::from("cached"); let context = ExactCacheContext { ttl: Some(Duration::from_secs(5)), @@ -180,3 +236,101 @@ async fn default_batch_operations_use_async_writes_and_stop_on_failure() { ] ); } + +#[rstest] +#[tokio::test] +async fn default_async_increment_delegates_to_the_sync_increment() { + struct SyncOnly; + + impl BaseCache for SyncOnly { + type Value = f64; + type Context = ExactCacheContext; + + fn get_ttl(&self, context: &Self::Context) -> Option { + context.ttl + } + + fn set_cache(&self, _: &str, _: f64, _: &ExactCacheContext) -> Result<(), Error> { + Ok(()) + } + + fn get_cache(&self, _: &str, _: &ExactCacheContext) -> Result, Error> { + Ok(None) + } + } + + impl CounterCache for SyncOnly { + fn increment_cache( + &self, + _: &str, + amount: f64, + _: ExactCacheContext, + ) -> Result { + Ok(amount * 10.0) + } + } + + for refresh_ttl in [false, true] { + assert_eq!( + SyncOnly + .async_increment("key", 2.0, ExactCacheContext::default(), refresh_ttl) + .await, + Ok(20.0) + ); + } +} + +#[rstest] +#[case::empty(Vec::new(), Vec::new())] +#[case::one(vec![operation("a", 1.0, Some(10))], vec![1.0])] +#[case::in_order( + vec![operation("a", 1.0, Some(10)), operation("b", 2.5, None), operation("a", -0.5, Some(20))], + vec![1.0, 3.5, 3.0], +)] +#[tokio::test] +async fn default_increment_pipeline_increments_each_operation_in_order( + #[case] operations: Vec, + #[case] expected: Vec, +) { + let cache = RecordingCounter::default(); + assert_eq!( + cache.async_increment_pipeline(operations.clone()).await, + Ok(expected) + ); + assert_eq!( + *cache.increments.lock().unwrap(), + operations + .into_iter() + .map(|operation| ( + operation.key, + operation.amount, + ExactCacheContext { ttl: operation.ttl }, + false, + )) + .collect::>() + ); +} + +#[rstest] +#[tokio::test] +async fn default_increment_pipeline_stops_at_the_first_failure() { + let cache = RecordingCounter::default(); + assert_eq!( + cache + .async_increment_pipeline(vec![ + operation("a", 1.0, None), + operation("unavailable", 1.0, None), + operation("skipped", 1.0, None), + ]) + .await, + Err(Error::Unavailable) + ); + let keys = cache + .increments + .lock() + .unwrap() + .iter() + .map(|(key, ..)| key.clone()) + .collect::>(); + assert_eq!(keys, ["a", "unavailable"]); +} diff --git a/litellm-rust/crates/cache/tests/codec.rs b/litellm-rust/crates/cache/tests/codec.rs index e24545caad6..5320545fd49 100644 --- a/litellm-rust/crates/cache/tests/codec.rs +++ b/litellm-rust/crates/cache/tests/codec.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use litellm_cache::{CacheCodec, Error, JsonCodec}; +use rstest::rstest; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -10,7 +11,7 @@ struct RoutingState { cooldown_seconds: u64, } -#[test] +#[rstest] fn json_codec_round_trips_typed_domain_values() { let codec = JsonCodec::::new(); let value = RoutingState { @@ -25,15 +26,15 @@ fn json_codec_round_trips_typed_domain_values() { ); } -#[test] -fn json_codec_rejects_malformed_and_wrongly_typed_entries() { +#[rstest] +#[case::malformed(b"not json")] +#[case::wrongly_typed(br#"{"deployment":12}"#)] +fn json_codec_rejects_malformed_and_wrongly_typed_entries(#[case] bytes: &[u8]) { let codec = JsonCodec::::new(); - for bytes in [b"not json".as_slice(), br#"{"deployment":12}"#.as_slice()] { - assert_eq!(codec.decode(bytes).unwrap_err(), Error::InvalidEntry); - } + assert_eq!(codec.decode(bytes).unwrap_err(), Error::InvalidEntry); } -#[test] +#[rstest] fn json_codec_propagates_encoding_errors() { let codec = JsonCodec::>::new(); let value = BTreeMap::from([((1, 2), "invalid JSON object key".into())]); diff --git a/litellm-rust/crates/cache/tests/dual.rs b/litellm-rust/crates/cache/tests/dual.rs index e7e8927f8d0..87dd76388ef 100644 --- a/litellm-rust/crates/cache/tests/dual.rs +++ b/litellm-rust/crates/cache/tests/dual.rs @@ -1,12 +1,15 @@ use std::{ + collections::HashMap, sync::{Arc, Mutex}, time::Duration, }; use litellm_cache::{ - BaseCache, BatchCache, CacheConnectionResult, ClaimCache, CounterCache, DeleteCache, DualCache, - Error, ExactCacheContext, FlushCache, ReadPolicy, RemoteFailurePolicy, WritePolicy, + BaseCache, BatchCache, BatchEntry, BulkDeleteCache, ClaimCache, CounterCache, DeleteCache, + DualCache, Error, ExactCacheContext, FlushCache, IncrementOperation, ReadPolicy, + RemoteFailurePolicy, SetCache, TtlCache, WritePolicy, }; +use rstest::{fixture, rstest}; struct TestCache { value: Mutex>, @@ -41,14 +44,6 @@ where fn get_cache(&self, _: &str, _: &ExactCacheContext) -> Result, Error> { Ok(self.value.lock().unwrap().clone()) } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - unreachable!() - } } impl BatchCache for TestCache where V: Clone + Send + Sync + 'static {} @@ -111,7 +106,7 @@ where } } -#[test] +#[rstest] fn failed_l2_increment_leaves_l1_unchanged() { let l1 = Arc::new(TestCache::new(Some(10.0), false)); let cache = DualCache::new(l1.clone(), Arc::new(TestCache::new(Some(20.0), true))); @@ -127,7 +122,7 @@ fn failed_l2_increment_leaves_l1_unchanged() { ); } -#[test] +#[rstest] fn claim_uses_l1_fallback_without_overwriting_an_eligible_winner() { let l1 = Arc::new(TestCache::new(Some("first".to_string()), false)); let cache = DualCache::new(l1, Arc::new(TestCache::new(None, true))) @@ -193,14 +188,6 @@ impl BaseCache for SyncPanics { } Ok(()) } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - unreachable!() - } } impl BatchCache for SyncPanics { @@ -208,11 +195,11 @@ impl BatchCache for SyncPanics { &self, keys: Vec, context: ExactCacheContext, - ) -> Result>, Error> { + ) -> Result>, Error> { assert_eq!(keys, ["missing"]); Ok(vec![match self.0.get_cache("missing", &context)? { - Some(value) => litellm_cache::BatchEntry::Hit(value), - None => litellm_cache::BatchEntry::Miss, + Some(value) => BatchEntry::Hit(value), + None => BatchEntry::Miss, }]) } } @@ -233,6 +220,7 @@ impl FlushCache for SyncPanics { } } +#[rstest] #[tokio::test] async fn async_operations_use_the_async_l2_methods() { let l1 = Arc::new(TestCache::new(None, false)); @@ -260,7 +248,7 @@ async fn async_operations_use_the_async_l2_methods() { .async_batch_get_cache(vec!["missing".into()], context.clone()) .await .unwrap(), - [litellm_cache::BatchEntry::Hit("remote".to_string())] + [BatchEntry::Hit("remote".to_string())] ); cache .async_set_cache("missing", "written".into(), context.clone()) @@ -294,14 +282,6 @@ impl BaseCache for Unavailable { fn get_cache(&self, _: &str, _: &ExactCacheContext) -> Result, Error> { Err(Error::Unavailable) } - - async fn disconnect(&self) -> Result<(), Error> { - Ok(()) - } - - async fn test_connection(&self) -> Result { - unreachable!() - } } impl BatchCache for Unavailable {} @@ -330,7 +310,7 @@ impl ClaimCache for Unavailable { } } -#[test] +#[rstest] fn remote_failure_policy_selects_propagation_or_the_local_tier() { let context = ExactCacheContext::default(); let strict = DualCache::new(Arc::new(TestCache::new(None, false)), Arc::new(Unavailable)); @@ -353,7 +333,7 @@ fn remote_failure_policy_selects_propagation_or_the_local_tier() { assert_eq!(l1.get_cache("key", &context), Ok(None)); } -#[test] +#[rstest] fn claim_fallback_does_not_hide_non_availability_errors() { let cache = DualCache::new( Arc::new(TestCache::new(Some("first".to_string()), false)), @@ -371,7 +351,7 @@ fn claim_fallback_does_not_hide_non_availability_errors() { ); } -#[test] +#[rstest] fn local_only_policies_never_touch_l2() { let l2 = Arc::new(TestCache::new(Some("remote".to_string()), false)); let cache = DualCache::new(Arc::new(TestCache::new(None, false)), l2.clone()) @@ -383,3 +363,474 @@ fn local_only_policies_never_touch_l2() { cache.set_cache("key", "local".into(), &context).unwrap(); assert_eq!(l2.get_cache("key", &context), Ok(Some("remote".into()))); } + +type Log = Arc>>; +type StoredSet = (Vec, Option); + +/// A keyed tier that logs every call, so tests can assert which tier ran and in what order. +struct Tier { + name: &'static str, + log: Log, + fail: bool, + counters: Mutex>, + sets: Mutex>, + ttls: HashMap, +} + +impl Tier { + fn new(name: &'static str, log: &Log) -> Self { + Self { + name, + log: log.clone(), + fail: false, + counters: Mutex::default(), + sets: Mutex::default(), + ttls: HashMap::new(), + } + } + + fn failing(self) -> Self { + Self { fail: true, ..self } + } + + fn with_counter(self, key: &str, value: f64) -> Self { + self.counters + .lock() + .unwrap() + .insert(key.into(), (value, ExactCacheContext::default())); + self + } + + fn with_ttl(mut self, key: &str, seconds: u64) -> Self { + self.ttls.insert(key.into(), Duration::from_secs(seconds)); + self + } + + fn record(&self, event: String) { + self.log + .lock() + .unwrap() + .push(format!("{} {event}", self.name)); + } + + fn counter(&self, key: &str) -> Option<(f64, ExactCacheContext)> { + self.counters.lock().unwrap().get(key).cloned() + } + + fn check(&self) -> Result<(), Error> { + if self.fail { + return Err(Error::Unavailable); + } + Ok(()) + } +} + +impl BaseCache for Tier { + type Value = f64; + type Context = ExactCacheContext; + + fn get_ttl(&self, context: &Self::Context) -> Option { + context.ttl + } + + fn set_cache(&self, key: &str, value: f64, context: &ExactCacheContext) -> Result<(), Error> { + self.check()?; + self.record(format!("set {key}={value}")); + self.counters + .lock() + .unwrap() + .insert(key.into(), (value, context.clone())); + Ok(()) + } + + fn get_cache(&self, key: &str, _: &ExactCacheContext) -> Result, Error> { + Ok(self.counter(key).map(|(value, _)| value)) + } +} + +impl CounterCache for Tier { + fn increment_cache( + &self, + key: &str, + amount: f64, + context: ExactCacheContext, + ) -> Result { + self.check()?; + let mut counters = self.counters.lock().unwrap(); + let value = counters.get(key).map_or(0.0, |(value, _)| *value) + amount; + counters.insert(key.into(), (value, context)); + Ok(value) + } + + async fn async_increment( + &self, + key: &str, + amount: f64, + context: ExactCacheContext, + refresh_ttl: bool, + ) -> Result { + self.record(format!( + "increment {key}+{amount} refresh_ttl={refresh_ttl}" + )); + self.increment_cache(key, amount, context) + } + + async fn async_increment_pipeline( + &self, + operations: Vec, + ) -> Result, Error> { + let keys = operations + .iter() + .map(|operation| operation.key.as_str()) + .collect::>(); + self.record(format!("pipeline {}", keys.join(","))); + operations + .into_iter() + .map(|operation| { + self.increment_cache( + &operation.key, + operation.amount, + ExactCacheContext { ttl: operation.ttl }, + ) + }) + .collect() + } +} + +impl DeleteCache for Tier { + fn delete_cache(&self, key: &str) -> Result<(), Error> { + self.check()?; + self.record(format!("delete {key}")); + self.counters.lock().unwrap().remove(key); + Ok(()) + } +} + +impl BulkDeleteCache for Tier { + async fn delete_cache_keys(&self, keys: Vec) -> Result { + self.check()?; + self.record(format!("delete_keys {}", keys.join(","))); + let mut counters = self.counters.lock().unwrap(); + Ok(keys + .iter() + .filter(|key| counters.remove(key.as_str()).is_some()) + .count()) + } +} + +impl SetCache for Tier { + type SetValue = String; + type SetResult = (); + + async fn async_set_cache_sadd( + &self, + key: &str, + values: Vec, + ttl: Option, + ) -> Result<(), Error> { + self.check()?; + self.record(format!("sadd {key} {}", values.join(","))); + self.sets.lock().unwrap().insert(key.into(), (values, ttl)); + Ok(()) + } +} + +impl TtlCache for Tier { + async fn async_get_ttl(&self, key: &str) -> Result, Error> { + self.check()?; + self.record(format!("ttl {key}")); + Ok(self.ttls.get(key).copied()) + } +} + +#[fixture] +fn log() -> Log { + Log::default() +} + +fn events(log: &Log) -> Vec { + log.lock().unwrap().clone() +} + +fn seconds(ttl: u64) -> ExactCacheContext { + ExactCacheContext { + ttl: Some(Duration::from_secs(ttl)), + } +} + +#[rstest] +#[case::window_semantics(false)] +#[case::refresh_on_every_write(true)] +#[tokio::test] +async fn async_increment_passes_refresh_ttl_to_l2_and_stores_its_result_locally( + log: Log, + #[case] refresh_ttl: bool, +) { + let l1 = Arc::new(Tier::new("l1", &log).with_counter("counter", 1.0)); + let l2 = Arc::new(Tier::new("l2", &log).with_counter("counter", 10.0)); + let cache = DualCache::new(l1.clone(), l2.clone()); + + assert_eq!( + cache + .async_increment("counter", 2.0, seconds(30), refresh_ttl) + .await, + Ok(12.0) + ); + assert_eq!( + events(&log), + [ + format!("l2 increment counter+2 refresh_ttl={refresh_ttl}"), + "l1 set counter=12".into(), + ] + ); + assert_eq!(l1.counter("counter"), Some((12.0, seconds(30)))); + assert_eq!(l2.counter("counter"), Some((12.0, seconds(30)))); +} + +#[rstest] +#[tokio::test] +async fn failed_async_l2_increment_leaves_l1_unchanged(log: Log) { + let l1 = Arc::new(Tier::new("l1", &log).with_counter("counter", 1.0)); + let cache = DualCache::new(l1.clone(), Arc::new(Tier::new("l2", &log).failing())) + .with_remote_failure_policy(RemoteFailurePolicy::UseLocal); + + assert_eq!( + cache + .async_increment("counter", 2.0, seconds(30), true) + .await, + Err(Error::Unavailable) + ); + assert_eq!( + l1.counter("counter"), + Some((1.0, ExactCacheContext::default())) + ); +} + +#[rstest] +#[case::empty(Vec::new(), Vec::new())] +#[case::one_key(vec![("a", 1.0, Some(10))], vec![6.0])] +#[case::repeated_and_mixed_ttls( + vec![("a", 1.0, Some(10)), ("b", 2.0, None), ("a", 3.0, Some(20))], + vec![6.0, 2.0, 9.0], +)] +#[tokio::test] +async fn async_increment_pipeline_runs_l2_first_and_l1_takes_each_remote_result( + log: Log, + #[case] operations: Vec<(&str, f64, Option)>, + #[case] expected: Vec, +) { + let operations = operations + .into_iter() + .map(|(key, amount, ttl)| IncrementOperation { + key: key.into(), + amount, + ttl: ttl.map(Duration::from_secs), + }) + .collect::>(); + let l1 = Arc::new(Tier::new("l1", &log).with_counter("a", 100.0)); + let l2 = Arc::new(Tier::new("l2", &log).with_counter("a", 5.0)); + let cache = DualCache::new(l1.clone(), l2); + + assert_eq!( + cache.async_increment_pipeline(operations.clone()).await, + Ok(expected.clone()) + ); + let keys = operations + .iter() + .map(|operation| operation.key.as_str()) + .collect::>(); + let mut expected_events = vec![format!("l2 pipeline {}", keys.join(","))]; + expected_events.extend( + operations + .iter() + .zip(&expected) + .map(|(operation, value)| format!("l1 set {}={value}", operation.key)), + ); + assert_eq!(events(&log), expected_events); + if let Some((operation, value)) = operations.iter().zip(&expected).next_back() { + assert_eq!( + l1.counter(&operation.key), + Some((*value, ExactCacheContext { ttl: operation.ttl })) + ); + } +} + +#[rstest] +#[tokio::test] +async fn failed_l2_increment_pipeline_leaves_l1_unchanged(log: Log) { + let l1 = Arc::new(Tier::new("l1", &log).with_counter("a", 1.0)); + let cache = DualCache::new(l1.clone(), Arc::new(Tier::new("l2", &log).failing())); + + assert_eq!( + cache + .async_increment_pipeline(vec![IncrementOperation { + key: "a".into(), + amount: 1.0, + ttl: None, + }]) + .await, + Err(Error::Unavailable) + ); + assert_eq!(events(&log), ["l2 pipeline a"]); + assert_eq!(l1.counter("a"), Some((1.0, ExactCacheContext::default()))); +} + +#[rstest] +#[case::both_tiers(WritePolicy::Both, &["l1 sadd members a,b", "l2 sadd members a,b"])] +#[case::local_only(WritePolicy::LocalOnly, &["l1 sadd members a,b"])] +#[tokio::test] +async fn set_add_writes_locally_then_remotely_unless_local_only( + log: Log, + #[case] write_policy: WritePolicy, + #[case] expected: &[&str], +) { + let l1 = Arc::new(Tier::new("l1", &log)); + let l2 = Arc::new(Tier::new("l2", &log)); + let cache = DualCache::new(l1.clone(), l2.clone()).with_write_policy(write_policy); + let ttl = Some(Duration::from_secs(45)); + + cache + .async_set_cache_sadd("members", vec!["a".into(), "b".into()], ttl) + .await + .unwrap(); + assert_eq!(events(&log), expected); + let stored = Some((vec!["a".to_string(), "b".to_string()], ttl)); + assert_eq!(l1.sets.lock().unwrap().get("members").cloned(), stored); + assert_eq!( + l2.sets.lock().unwrap().get("members").cloned(), + stored.filter(|_| write_policy == WritePolicy::Both) + ); +} + +#[rstest] +#[tokio::test] +async fn failed_local_set_add_never_reaches_l2(log: Log) { + let cache = DualCache::new( + Arc::new(Tier::new("l1", &log).failing()), + Arc::new(Tier::new("l2", &log)), + ); + assert_eq!( + cache + .async_set_cache_sadd("members", vec!["a".into()], None) + .await, + Err(Error::Unavailable) + ); + assert!(events(&log).is_empty()); +} + +#[rstest] +#[case::empty(None, &[], &[])] +#[case::default_batch_size(None, &["a", "b", "c"], &["a,b,c"])] +#[case::chunked(Some(2), &["a", "b", "c", "d", "e"], &["a,b", "c,d", "e"])] +#[case::exact_chunks(Some(2), &["a", "b", "c", "d"], &["a,b", "c,d"])] +#[case::zero_means_one_per_chunk(Some(0), &["a", "b"], &["a", "b"])] +#[tokio::test] +async fn bulk_delete_removes_every_key_locally_then_remotely_in_chunks( + log: Log, + #[case] batch_size: Option, + #[case] keys: &[&str], + #[case] chunks: &[&str], +) { + let l1 = Tier::new("l1", &log); + let l2 = Tier::new("l2", &log); + for key in keys.iter().step_by(2) { + l2.counters + .lock() + .unwrap() + .insert((*key).into(), (1.0, ExactCacheContext::default())); + } + let l1 = Arc::new(l1); + let mut cache = DualCache::new(l1, Arc::new(l2)); + if let Some(batch_size) = batch_size { + cache = cache.with_delete_batch_size(batch_size); + } + + assert_eq!( + cache + .delete_cache_keys(keys.iter().map(|key| (*key).into()).collect()) + .await, + Ok(keys.len().div_ceil(2)) + ); + let expected = keys + .iter() + .map(|key| format!("l1 delete {key}")) + .chain(chunks.iter().map(|chunk| format!("l2 delete_keys {chunk}"))) + .collect::>(); + assert_eq!(events(&log), expected); +} + +#[rstest] +#[tokio::test] +async fn bulk_delete_stops_before_l2_when_the_local_delete_fails(log: Log) { + let cache = DualCache::new( + Arc::new(Tier::new("l1", &log).failing()), + Arc::new(Tier::new("l2", &log)), + ); + assert_eq!( + cache.delete_cache_keys(vec!["a".into()]).await, + Err(Error::Unavailable) + ); + assert!(events(&log).is_empty()); +} + +#[rstest] +#[case::local_hit("both", Some(10), &["l1 ttl both"])] +#[case::remote_fallback("remote", Some(20), &["l1 ttl remote", "l2 ttl remote"])] +#[case::missing_everywhere("missing", None, &["l1 ttl missing", "l2 ttl missing"])] +#[tokio::test] +async fn ttl_reads_local_then_remote( + log: Log, + #[case] key: &str, + #[case] expected: Option, + #[case] expected_events: &[&str], +) { + let cache = DualCache::new( + Arc::new(Tier::new("l1", &log).with_ttl("both", 10)), + Arc::new( + Tier::new("l2", &log) + .with_ttl("both", 99) + .with_ttl("remote", 20), + ), + ); + assert_eq!( + cache.async_get_ttl(key).await, + Ok(expected.map(Duration::from_secs)) + ); + assert_eq!(events(&log), expected_events); +} + +/// Python `local_only=True`: the increment and the pipeline stay on the local tier. +#[rstest] +#[tokio::test] +async fn local_only_writes_increment_the_local_tier_alone(log: Log) { + let l1 = Arc::new(Tier::new("l1", &log).with_counter("a", 1.0)); + let l2 = Arc::new(Tier::new("l2", &log).with_counter("a", 10.0)); + let cache = DualCache::new(l1.clone(), l2.clone()).with_write_policy(WritePolicy::LocalOnly); + + assert_eq!(cache.increment_cache("a", 2.0, seconds(30)), Ok(3.0)); + assert_eq!( + cache.async_increment("a", 1.0, seconds(30), true).await, + Ok(4.0) + ); + let operations = vec![ + IncrementOperation { + key: "a".into(), + amount: 1.0, + ttl: Some(Duration::from_secs(10)), + }, + IncrementOperation { + key: "b".into(), + amount: 2.0, + ttl: None, + }, + ]; + assert_eq!( + cache.async_increment_pipeline(operations).await, + Ok(vec![5.0, 2.0]) + ); + assert_eq!( + events(&log), + ["l1 set a=3", "l1 set a=4", "l1 set a=5", "l1 set b=2"] + ); + assert_eq!(l2.counter("a"), Some((10.0, ExactCacheContext::default()))); +} diff --git a/litellm-rust/crates/cache/tests/semantic.rs b/litellm-rust/crates/cache/tests/semantic.rs new file mode 100644 index 00000000000..97a552a8010 --- /dev/null +++ b/litellm-rust/crates/cache/tests/semantic.rs @@ -0,0 +1,270 @@ +use litellm_cache::{ + Error, SemanticCacheContext, + semantic::{ + Embedder, PreparedEmbedding, prompt_from_context, prompt_from_messages, str_from_messages, + }, +}; +use rstest::rstest; +use serde_json::{Value, json}; + +fn context(messages: Option, input: Option) -> SemanticCacheContext { + SemanticCacheContext { + messages, + input, + ..SemanticCacheContext::default() + } +} + +#[rstest] +#[case::empty(json!([]), "")] +#[case::string_content(json!([{"role": "user", "content": "hello"}]), "hello")] +#[case::concatenates_messages( + json!([{"role": "system", "content": "be brief. "}, {"role": "user", "content": "hello"}]), + "be brief. hello", +)] +#[case::text_parts( + json!([{"role": "user", "content": [ + {"type": "text", "text": "What is "}, + {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}, + {"type": "text", "text": "this?"}, + ]}]), + "What is this?", +)] +#[case::missing_null_and_empty_content( + json!([{"role": "assistant"}, {"role": "assistant", "content": null}, {"role": "user", "content": ""}]), + "", +)] +#[case::search_results_hidden_behind_small_content( + json!([{"role": "tool", "content": "small", "search_results": [ + {"source": "s", "title": "t", "content": [{"text": "hidden payload"}]}, + ]}]), + "smallsthidden payload", +)] +#[case::title_only_search_result( + json!([{"role": "tool", "content": "small", "search_results": [ + {"source": "s", "title": "long title", "content": []}, + ]}]), + "smallslong title", +)] +#[case::search_results_without_content( + json!([{"role": "tool", "search_results": [{"source": "s", "title": "t"}]}]), + "st", +)] +#[case::search_result_fields_in_python_order( + json!([{"role": "tool", "content": "c", "search_results": [ + {"citations": {"enabled": true}, "content": [{"text": "body"}], "title": "t", "source": "s"}, + {"source": "s2"}, + ]}]), + r#"cstbody{"enabled":true}s2"#, +)] +#[case::null_citations_skipped( + json!([{"role": "tool", "content": "c", "search_results": [ + {"source": "s", "citations": null}, + ]}]), + "cs", +)] +#[case::non_string_and_non_object_entries_skipped( + json!([{"role": "tool", "content": "c", "search_results": [ + "junk", + {"source": 1, "title": null, "content": ["junk", {"text": 3}, {"text": "kept"}]}, + ]}]), + "ckept", +)] +#[case::non_list_search_results_skipped( + json!([{"role": "tool", "content": "c", "search_results": {"source": "s"}}]), + "c", +)] +#[case::citations_compact_in_insertion_order( + json!([{"role": "tool", "search_results": [ + {"citations": {"z": 1, "a": [1.5, true, null], "m": {"k": "v"}}}, + ]}]), + r#"{"z":1,"a":[1.5,true,null],"m":{"k":"v"}}"#, +)] +#[case::citations_ensure_ascii( + json!([{"role": "tool", "search_results": [{"citations": ["caf\u{e9}", "\u{4e2d}"]}]}]), + r#"["caf\u00e9","\u4e2d"]"#, +)] +#[case::citations_astral_chars_as_surrogate_pairs( + json!([{"role": "tool", "search_results": [{"citations": "\u{1f600}"}]}]), + r#""\ud83d\ude00""#, +)] +#[case::citations_escapes( + json!([{"role": "tool", "search_results": [{"citations": "q\"\\\n\t\u{1}/"}]}]), + r#""q\"\\\n\t\u0001/""#, +)] +#[case::citations_large_float_exponent( + json!([{"role": "tool", "search_results": [{"citations": [1e20, 1.0]}]}]), + "[1e+20,1.0]", +)] +#[case::citations_scalars( + json!([{"role": "tool", "search_results": [{"citations": false}, {"citations": 3}]}]), + "false3", +)] +fn str_from_messages_matches_python(#[case] messages: Value, #[case] expected: &str) { + assert_eq!(str_from_messages(messages.as_array().unwrap()), expected); +} + +#[rstest] +#[case::no_messages(None, None)] +#[case::empty_messages(Some(json!([])), None)] +#[case::messages_not_a_list(Some(json!("hello")), None)] +#[case::messages(Some(json!([{"content": "hello"}])), Some("hello"))] +#[case::messages_without_text(Some(json!([{"content": null}])), Some(""))] +fn prompt_from_messages_reads_messages_only( + #[case] messages: Option, + #[case] expected: Option<&str>, +) { + let context = context(messages, Some(json!("responses prompt"))); + assert_eq!(prompt_from_messages(&context).as_deref(), expected); +} + +#[rstest] +#[case::prefers_messages( + Some(json!([{"content": "message prompt"}])), + Some(json!("responses prompt")), + Some("message prompt"), +)] +#[case::empty_messages_fall_back_to_input( + Some(json!([])), + Some(json!("responses prompt")), + Some("responses prompt"), +)] +#[case::messages_without_text_keep_an_empty_prompt( + Some(json!([{"content": null}])), + Some(json!("x")), + Some(""), +)] +#[case::nothing(None, None, None)] +#[case::null_input(None, Some(Value::Null), None)] +#[case::blank_string(None, Some(json!(" ")), None)] +#[case::trimmed_string( + None, + Some(json!(" What is the capital of France?\n")), + Some("What is the capital of France?"), +)] +#[case::image_only( + None, + Some(json!([{"type": "input_image", "image_url": "https://example.com"}])), + None, +)] +#[case::structured_input( + None, + Some(json!([{"role": "user", "content": [ + {"type": "input_text", "text": "What is the capital of France?"}, + {"type": "input_text", "text": "Answer briefly."}, + {"type": "input_image", "image_url": "https://example.com/paris.png"}, + ]}])), + Some("What is the capital of France?\nAnswer briefly."), +)] +#[case::model_objects_after_dump( + None, + Some(json!([ + {"content": [{"text": "model dump prompt"}]}, + {"content": [{"output_text": "dict prompt"}]}, + {"content": [{"input_text": "inline prompt"}]}, + {"content": [{"type": "input_image", "image_url": "https://example.com"}]}, + ])), + Some("model dump prompt\ndict prompt\ninline prompt"), +)] +#[case::object_content( + None, + Some(json!({"content": [{"text": "object content prompt"}]})), + Some("object content prompt"), +)] +#[case::string_content(None, Some(json!({"content": " inline "})), Some("inline"))] +#[case::null_content_uses_text_keys( + None, + Some(json!({"content": null, "output": "tool output"})), + Some("tool output"), +)] +#[case::content_wins_over_text(None, Some(json!({"content": [], "text": "ignored"})), None)] +#[case::text_key_precedence( + None, + Some(json!({"output_text": "d", "input_text": "c", "output": "b", "text": "a"})), + Some("a"), +)] +#[case::input_text_key(None, Some(json!({"input_text": "only input"})), Some("only input"))] +#[case::output_text_key(None, Some(json!({"output_text": "only output"})), Some("only output"))] +#[case::non_string_text_keys_skipped( + None, + Some(json!({"text": 1, "output": "fallback"})), + Some("fallback"), +)] +#[case::nested_lists(None, Some(json!([["a", [" b "]], "", "c"])), Some("a\nb\nc"))] +#[case::scalars_ignored(None, Some(json!([1, true, null, "kept"])), Some("kept"))] +fn prompt_from_context_matches_python( + #[case] messages: Option, + #[case] input: Option, + #[case] expected: Option<&str>, +) { + assert_eq!( + prompt_from_context(&context(messages, input)).as_deref(), + expected + ); +} + +/// Python `test_redis_semantic_cache_prompt_extraction_skips_blank_dict_text_keys`: a blank +/// text key falls through to the next one. +#[rstest] +#[case::blank_text_falls_through( + json!({"text": " ", "input_text": "fallback prompt"}), + "fallback prompt", +)] +fn prompt_from_context_skips_blank_text_keys(#[case] input: Value, #[case] expected: &str) { + assert_eq!( + prompt_from_context(&context(None, Some(input))).as_deref(), + Some(expected) + ); +} + +/// Where `json.dumps(..., separators=(",", ":"))` and Python `str.strip` differ from +/// `semantic.rs`: ensure_ascii escapes DEL, small floats keep Python's two-digit exponent, and +/// strip also removes the ASCII information separators. +#[rstest] +#[case::del_is_escaped(json!([{"search_results": [{"citations": "\u{7f}"}]}]), None, r#""\u007f""#)] +#[case::small_float_exponent(json!([{"search_results": [{"citations": 1.5e-7}]}]), None, "1.5e-07")] +#[case::float_at_positional_floor(json!([{"search_results": [{"citations": 1e-4}]}]), None, "0.0001")] +#[case::float_at_scientific_ceiling(json!([{"search_results": [{"citations": 1e16}]}]), None, "1e+16")] +#[case::large_float(json!([{"search_results": [{"citations": [1.25e20, -2.5, 3.0]}]}]), None, "[1.25e+20,-2.5,3.0]")] +#[case::strip_information_separators(json!([]), Some(json!("\u{1c}a\u{1f}")), "a")] +fn python_serialization_edge_cases( + #[case] messages: Value, + #[case] input: Option, + #[case] expected: &str, +) { + let actual = match input { + Some(input) => prompt_from_context(&context(None, Some(input))).unwrap_or_default(), + None => str_from_messages(messages.as_array().unwrap()), + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[tokio::test] +async fn prepared_embedding_returns_its_vector_for_any_prompt() { + let embedding = PreparedEmbedding(vec![0.1, 0.2, 0.3]); + let metadata = json!({"tenant": "team"}); + assert_eq!(embedding.embed("a", None), Ok(vec![0.1, 0.2, 0.3])); + assert_eq!( + embedding.async_embed("b", Some(&metadata)).await, + Ok(vec![0.1, 0.2, 0.3]) + ); +} + +#[rstest] +#[tokio::test] +async fn embedders_default_to_async_only() { + struct AsyncOnly; + + impl Embedder for AsyncOnly { + async fn async_embed(&self, prompt: &str, _: Option<&Value>) -> Result, Error> { + Ok(vec![prompt.len() as f32]) + } + } + + assert_eq!( + AsyncOnly.embed("abc", None), + Err(Error::UnsupportedOperation) + ); + assert_eq!(AsyncOnly.async_embed("abc", None).await, Ok(vec![3.0])); +} diff --git a/litellm-rust/crates/python-bridge/src/cache/activation.rs b/litellm-rust/crates/python-bridge/src/cache/activation.rs new file mode 100644 index 00000000000..8d2c340dec8 --- /dev/null +++ b/litellm-rust/crates/python-bridge/src/cache/activation.rs @@ -0,0 +1,111 @@ +use litellm_cache_gcs::{DEFAULT_ENDPOINT, GcsConfig}; +use litellm_cache_redis_semantic::RedisSemanticConfig; +use litellm_host_python::{release_gil, run_sync_value}; +use litellm_http::ClientVariant; +use pyo3::prelude::*; + +use super::{ + cache_error, + config::{CacheBackendConfig, NativeCacheConfig, UnsupportedCacheConfig}, + embedder::PythonEmbedder, + host_client, + native::NativeResponseCache, +}; +use crate::errors::RustBridgeDeclined; + +fn declined(reason: UnsupportedCacheConfig) -> PyErr { + RustBridgeDeclined::new_err(reason.message()) +} + +/// Builds the native backend a `Cache` facade's projected configuration describes. `backend` is +/// the facade's `.cache` object, which owns embedding for the Python-embedded semantic caches. +pub(super) fn activate( + py: Python<'_>, + backend: &Bound<'_, PyAny>, + config: NativeCacheConfig, +) -> PyResult { + let policy = config.policy; + let service = match config.backend { + CacheBackendConfig::Memory(memory) => { + NativeResponseCache::memory(memory.capacity, memory.default_ttl, memory.max_entry_bytes) + } + CacheBackendConfig::Redis(redis) => { + let url = redis.connection.native_url().map_err(declined)?; + let flush_size = policy.redis_flush_size.map(|_| redis.flush_size); + release_gil(py, move || { + NativeResponseCache::redis( + &url, + &redis.topology, + Some(redis.default_ttl), + redis.namespace, + ) + }) + .map_err(cache_error)? + .with_redis_flush_size(flush_size) + } + CacheBackendConfig::S3(s3) => { + let http = host_client(py, ClientVariant::NoRedirect)?; + run_sync_value( + py, + async move { Ok(NativeResponseCache::s3(*s3, http).await) }, + )? + } + CacheBackendConfig::Gcs(gcs) => NativeResponseCache::gcs( + GcsConfig { + bucket_name: gcs.bucket_name, + gcs_path: Some(gcs.key_prefix), + path_service_account: gcs.path_service_account, + endpoint: DEFAULT_ENDPOINT.to_owned(), + }, + host_client(py, ClientVariant::NoRedirect)?, + None, + ), + CacheBackendConfig::Disk(disk) => { + release_gil(py, move || NativeResponseCache::disk(&disk.directory)) + .map_err(cache_error)? + } + CacheBackendConfig::AzureBlob(azure) => { + let http = host_client(py, ClientVariant::NoRedirect)?; + run_sync_value(py, async move { + NativeResponseCache::azure_blob(&azure.account_url, &azure.container, http) + .await + .map_err(cache_error) + })? + } + CacheBackendConfig::RedisSemantic(semantic) => { + let url = semantic.native_url().map_err(declined)?.to_owned(); + let embedder = PythonEmbedder::new(backend.clone().unbind()); + let semantic_config = RedisSemanticConfig { + index_name: semantic.index_name, + similarity_threshold: semantic.similarity_threshold as f32, + }; + release_gil(py, move || { + NativeResponseCache::redis_semantic(&url, embedder, semantic_config) + }) + .map_err(cache_error)? + } + CacheBackendConfig::ValkeySemantic(valkey) => { + let url = valkey.connection.native_url().map_err(declined)?; + let embedder = PythonEmbedder::new(backend.clone().unbind()); + release_gil(py, move || { + NativeResponseCache::valkey_semantic( + &url, + valkey.similarity_threshold, + valkey.index_name, + embedder, + ) + }) + .map_err(cache_error)? + } + CacheBackendConfig::QdrantSemantic(qdrant) => { + let client = host_client(py, ClientVariant::Provider)?; + run_sync_value(py, async move { + let runtime = tokio::runtime::Handle::current(); + NativeResponseCache::qdrant_semantic(*qdrant, client, runtime) + .await + .map_err(cache_error) + })? + } + }; + Ok(service.with_scope(policy.semantic_cache_scope)) +} diff --git a/litellm-rust/crates/python-bridge/src/cache/binding.rs b/litellm-rust/crates/python-bridge/src/cache/binding.rs index 273d3f9ca4e..56f1bff2853 100644 --- a/litellm-rust/crates/python-bridge/src/cache/binding.rs +++ b/litellm-rust/crates/python-bridge/src/cache/binding.rs @@ -9,11 +9,12 @@ use pyo3::{ use serde_json::Value; use super::{ + activation::activate, cache_error, callback::PythonCallback, - config::{CacheBackendConfig, CacheConfigProjection, NativeCacheConfig}, + config::{CacheConfigProjection, NativeCacheConfig}, future::{ready_none, ready_value}, - native::NativeResponseCache, + native::{NativeResponseCache, SemanticReply}, request::{now, request, requests}, }; use crate::errors::RustBridgeDeclined; @@ -76,23 +77,9 @@ impl ResolvedCache { return Err(RustBridgeDeclined::new_err(reason.message())); } }; - let service = match config.backend { - CacheBackendConfig::Memory(memory) => NativeResponseCache::memory( - memory.capacity, - memory.default_ttl, - memory.max_entry_bytes, - ), - _ => { - return Err(RustBridgeDeclined::new_err( - "native response cache activation is not implemented for this backend", - )); - } - }; - Ok(Self::new(CacheBinding::Native( - service - .with_scope(config.policy.semantic_cache_scope) - .with_redis_flush_size(config.policy.redis_flush_size), - ))) + let backend = cache.getattr("cache")?; + let service = activate(cache.py(), &backend, config)?; + Ok(Self::new(CacheBinding::Native(service))) } #[getter] @@ -127,6 +114,41 @@ impl ResolvedCache { } } + /// `(response, similarity)`: the similarity is `None` when the backend reports none. + fn lookup_semantic(&self, py: Python<'_>, request: &Bound<'_, PyAny>) -> PyResult> { + self.check_process()?; + match &self.binding { + CacheBinding::Native(service) => { + let request = self::request(request)?; + let service = service.clone(); + let lookup = release_gil(py, move || service.lookup_semantic(&request, now())) + .map_err(cache_error)?; + to_py(py, &SemanticReply::from(lookup)) + } + CacheBinding::Disabled => to_py(py, &SemanticReply(None, None)), + CacheBinding::PythonCallback(_) => Err(PyRuntimeError::new_err( + "semantic lookups require a native cache binding", + )), + } + } + + fn async_lookup_semantic<'py>( + &self, + py: Python<'py>, + request: &Bound<'py, PyAny>, + ) -> PyResult> { + self.check_process()?; + match &self.binding { + CacheBinding::Native(service) => { + service.async_lookup_semantic_py(py, self::request(request)?) + } + CacheBinding::Disabled => ready_value(py, &SemanticReply(None, None)), + CacheBinding::PythonCallback(_) => Err(PyRuntimeError::new_err( + "semantic lookups require a native cache binding", + )), + } + } + #[pyo3(signature = (request, response, *, callback_kwargs=None))] fn store( &self, diff --git a/litellm-rust/crates/python-bridge/src/cache/config.rs b/litellm-rust/crates/python-bridge/src/cache/config.rs index b6e08102e18..6e25f07efa1 100644 --- a/litellm-rust/crates/python-bridge/src/cache/config.rs +++ b/litellm-rust/crates/python-bridge/src/cache/config.rs @@ -13,12 +13,7 @@ use pyo3::{ use super::{identity::BackendIdentity, native::NativeResponseCache, request::duration}; -#[allow(dead_code, reason = "consumed by the cache activation follow-up")] pub(super) struct CachePolicy { - pub(super) mode: String, - pub(super) ttl: Option, - pub(super) namespace: Option, - pub(super) supported_call_types: Option>, pub(super) redis_flush_size: Option, pub(super) semantic_cache_scope: String, } @@ -46,7 +41,6 @@ pub(super) enum CertificateRequirement { Required, } -#[allow(dead_code, reason = "consumed by the cache activation follow-up")] pub(super) struct RedisTlsConfig { pub(super) certificate_requirement: CertificateRequirement, pub(super) check_hostname: bool, @@ -56,7 +50,6 @@ pub(super) struct RedisTlsConfig { pub(super) client_key: Option, } -#[allow(dead_code, reason = "consumed by the cache activation follow-up")] pub(super) struct RedisConnectionConfig { pub(super) host: String, pub(super) port: u16, @@ -73,7 +66,6 @@ pub(super) struct RedisConnectionConfig { pub(super) tls: Option, } -#[allow(dead_code, reason = "consumed by the cache activation follow-up")] pub(super) struct RedisCacheConfig { pub(super) default_ttl: Duration, pub(super) namespace: Option, @@ -94,17 +86,10 @@ pub(super) struct AzureBlobCacheConfig { pub(super) container: String, } -#[allow( - dead_code, - reason = "embedding settings are projected so drift falls back to Python" -)] pub(super) struct RedisSemanticCacheConfig { pub(super) redis_url: String, pub(super) index_name: String, pub(super) similarity_threshold: f64, - pub(super) embedding_model: String, - pub(super) embedding_max_input_tokens: Option, - pub(super) embedding_timeout: Option, } struct RedisClientProjection<'py> { @@ -118,11 +103,13 @@ struct RedisClientProjection<'py> { const REDIS_PY_DEFAULT_MAX_CONNECTIONS: usize = 1 << 31; -#[allow(dead_code, reason = "consumed by the cache activation follow-up")] +/// The read and write timeout every native Redis connection uses, which is also `RedisCache`'s +/// default `socket_timeout`. +const NATIVE_REDIS_SOCKET_TIMEOUT: Duration = Duration::from_secs(5); + pub(super) struct ValkeySemanticCacheConfig { pub(super) similarity_threshold: f64, pub(super) index_name: String, - pub(super) embedding_model: String, pub(super) connection: RedisConnectionConfig, } @@ -147,6 +134,93 @@ impl QdrantSemanticCacheConfig { } } +impl RedisTlsConfig { + /// Whether redis-rs with rustls behaves like this redis-py `SSLConnection`: it verifies the + /// certificate chain against the system roots and always checks the hostname. + fn native(&self) -> Result<(), UnsupportedCacheConfig> { + if self.ca_certificate.is_some() + || self.ca_data.is_some() + || self.client_certificate.is_some() + || self.client_key.is_some() + { + return Err(UnsupportedCacheConfig::RedisTlsCertificates); + } + if self.certificate_requirement == CertificateRequirement::None || !self.check_hostname { + return Err(UnsupportedCacheConfig::RedisTlsVerification); + } + Ok(()) + } +} + +impl RedisConnectionConfig { + /// The redis-rs URL for this connection, or the first setting the native client cannot + /// honor. The native pool and socket timeouts are fixed, so only redis-py's unbounded pool, + /// its unset timeouts and `RedisCache`'s five-second `socket_timeout` map onto them. + pub(super) fn native_url(&self) -> Result { + if self.pool_size != REDIS_PY_DEFAULT_MAX_CONNECTIONS { + return Err(UnsupportedCacheConfig::RedisPoolSize); + } + if self + .read_timeout + .is_some_and(|timeout| timeout != NATIVE_REDIS_SOCKET_TIMEOUT) + || self.connect_timeout.is_some() + { + return Err(UnsupportedCacheConfig::RedisTimeout); + } + if self.socket_keepalive == Some(true) { + return Err(UnsupportedCacheConfig::RedisKeepalive); + } + if !self.health_check_interval.is_zero() { + return Err(UnsupportedCacheConfig::RedisHealthCheck); + } + if self.client_name.is_some() { + return Err(UnsupportedCacheConfig::RedisClientName); + } + let scheme = match &self.tls { + None => "redis", + Some(tls) => { + tls.native()?; + "rediss" + } + }; + let host = if self.host.contains(':') { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + let mut url = url::Url::parse(&format!( + "{scheme}://{host}:{}/{}", + self.port, self.database + )) + .map_err(|_| UnsupportedCacheConfig::RedisConnection)?; + if let Some(username) = &self.username { + url.set_username(username) + .map_err(|()| UnsupportedCacheConfig::RedisConnection)?; + } + if let Some(password) = &self.password { + url.set_password(Some(password)) + .map_err(|()| UnsupportedCacheConfig::RedisConnection)?; + } + if self.protocol == RedisProtocol::Resp3 { + url.set_query(Some("protocol=resp3")); + } + Ok(url.into()) + } +} + +impl RedisSemanticCacheConfig { + /// redisvl hands `redis_url` to redis-py, which reads TLS and socket options from the URL; + /// redis-rs ignores those, so only a plain URL keeps its meaning. + pub(super) fn native_url(&self) -> Result<&str, UnsupportedCacheConfig> { + let url = url::Url::parse(&self.redis_url) + .map_err(|_| UnsupportedCacheConfig::RedisSemanticUrl)?; + if !matches!(url.scheme(), "redis" | "unix") || url.query().is_some() { + return Err(UnsupportedCacheConfig::RedisSemanticUrl); + } + Ok(&self.redis_url) + } +} + pub(super) enum CacheBackendConfig { Memory(MemoryCacheConfig), Redis(Box), @@ -159,7 +233,6 @@ pub(super) enum CacheBackendConfig { QdrantSemantic(Box), } -#[allow(dead_code, reason = "consumed by the cache activation follow-up")] pub(super) struct NativeCacheConfig { pub(super) policy: CachePolicy, pub(super) backend: CacheBackendConfig, @@ -178,6 +251,15 @@ pub(super) enum UnsupportedCacheConfig { DiskStore, QdrantEndpoint, SemanticEmbedding, + RedisPoolSize, + RedisTimeout, + RedisKeepalive, + RedisHealthCheck, + RedisClientName, + RedisTlsCertificates, + RedisTlsVerification, + RedisSemanticUrl, + ValkeyTls, } impl UnsupportedCacheConfig { @@ -197,6 +279,28 @@ impl UnsupportedCacheConfig { "native Qdrant requires the default REST port so the gRPC port can be derived" } Self::SemanticEmbedding => "native semantic embedding requires Python", + Self::RedisPoolSize => { + "native Redis uses a fixed connection pool; max_connections requires Python" + } + Self::RedisTimeout => { + "native Redis uses fixed socket timeouts; socket_timeout and \ + socket_connect_timeout require Python" + } + Self::RedisKeepalive => "native Redis does not support socket_keepalive", + Self::RedisHealthCheck => "native Redis does not support health_check_interval", + Self::RedisClientName => "native Redis does not support client_name", + Self::RedisTlsCertificates => { + "native Redis TLS does not support ssl_ca_certs, ssl_ca_data, ssl_certfile or \ + ssl_keyfile" + } + Self::RedisTlsVerification => { + "native Redis TLS always verifies the certificate and hostname; \ + ssl_cert_reqs=none and ssl_check_hostname=false require Python" + } + Self::RedisSemanticUrl => { + "native Redis semantic cache does not support TLS or query options in redis_url" + } + Self::ValkeyTls => "native Valkey semantic cache does not support TLS connections", } } } @@ -211,12 +315,6 @@ impl NativeCacheConfig { pub(super) fn project(facade: &Bound<'_, PyAny>) -> PyResult { let backend_name = facade.getattr("type")?.extract::()?; let policy = CachePolicy { - mode: facade.getattr("mode")?.extract::()?, - ttl: optional_duration(facade.getattr("ttl")?)?, - namespace: optional_string(facade.getattr("namespace")?)?, - supported_call_types: facade - .getattr("supported_call_types")? - .extract::>>()?, redis_flush_size: facade .getattr("redis_flush_size")? .extract::>()?, @@ -475,13 +573,6 @@ pub(super) fn project_redis_semantic( .extract::>()? .unwrap_or_else(|| "litellm_semantic_cache_index".into()), similarity_threshold: backend.getattr("similarity_threshold")?.extract::()?, - embedding_model: backend.getattr("embedding_model")?.extract::()?, - embedding_max_input_tokens: backend - .getattr("embedding_max_input_tokens")? - .extract::>()?, - embedding_timeout: backend - .getattr("embedding_timeout")? - .extract::>()?, }) } @@ -592,37 +683,48 @@ fn project_redis( if has_value(&resolved, "credential_provider")? { return Ok(Err(UnsupportedCacheConfig::RedisCredentials)); } - - let protocol = match optional_i64(&resolved, "protocol")?.unwrap_or(2) { - 2 => RedisProtocol::Resp2, - 3 => RedisProtocol::Resp3, - _ => return Err(PyValueError::new_err("unsupported Redis protocol version")), - }; - let health_check_interval = - duration(optional_f64(&resolved, "health_check_interval")?.unwrap_or(0.0))?; Ok(Ok(RedisCacheConfig { default_ttl: duration(backend.getattr("default_ttl")?.extract::()?)?, namespace: optional_attribute_string(backend, "namespace")?, flush_size: backend.getattr("redis_flush_size")?.extract::()?, topology, - connection: RedisConnectionConfig { - host, - port, - database: optional_i64(&resolved, "db")?.unwrap_or(0), - username: optional_dict_string(&resolved, "username")?, - password: optional_dict_string(&resolved, "password")?, - protocol, - pool_size, - read_timeout: optional_dict_duration(&resolved, "socket_timeout")?, - connect_timeout: optional_dict_duration(&resolved, "socket_connect_timeout")?, - socket_keepalive: optional_bool(&resolved, "socket_keepalive")?, - health_check_interval, - client_name: optional_dict_string(&resolved, "client_name")?, - tls, - }, + connection: resolved_connection(&resolved, host, port, pool_size, tls)?, })) } +/// The connection settings redis-py resolved for one client's pool. +#[inline(never)] +fn resolved_connection( + resolved: &Bound<'_, PyDict>, + host: String, + port: u16, + pool_size: usize, + tls: Option, +) -> PyResult { + let protocol = match optional_i64(resolved, "protocol")?.unwrap_or(2) { + 2 => RedisProtocol::Resp2, + 3 => RedisProtocol::Resp3, + _ => return Err(PyValueError::new_err("unsupported Redis protocol version")), + }; + Ok(RedisConnectionConfig { + host, + port, + database: optional_i64(resolved, "db")?.unwrap_or(0), + username: optional_dict_string(resolved, "username")?, + password: optional_dict_string(resolved, "password")?, + protocol, + pool_size, + read_timeout: optional_dict_duration(resolved, "socket_timeout")?, + connect_timeout: optional_dict_duration(resolved, "socket_connect_timeout")?, + socket_keepalive: optional_bool(resolved, "socket_keepalive")?, + health_check_interval: duration( + optional_f64(resolved, "health_check_interval")?.unwrap_or(0.0), + )?, + client_name: optional_dict_string(resolved, "client_name")?, + tls, + }) +} + #[inline(never)] fn project_s3( backend: &Bound<'_, PyAny>, @@ -831,31 +933,22 @@ fn project_valkey_semantic( } } if is_tls { + return Ok(Err(UnsupportedCacheConfig::ValkeyTls)); + } + let host = required_string(&resolved, "host")?; + if host.is_empty() { return Ok(Err(UnsupportedCacheConfig::RedisConnection)); } - let connection = RedisConnectionConfig { - host: required_string(&resolved, "host")?, - port: u16::try_from(required_i64(&resolved, "port")?) - .map_err(|_| PyValueError::new_err("invalid Redis port"))?, - database: optional_i64(&resolved, "db")?.unwrap_or(0), - username: optional_dict_string(&resolved, "username")?, - password: optional_dict_string(&resolved, "password")?, - protocol: RedisProtocol::Resp2, - pool_size: pool.getattr("max_connections")?.extract::()?, - read_timeout: None, - connect_timeout: None, - socket_keepalive: None, - health_check_interval: Duration::ZERO, - client_name: None, - tls: None, - }; - if connection.host.is_empty() { - return Ok(Err(UnsupportedCacheConfig::RedisConnection)); - } + let connection = resolved_connection( + &resolved, + host, + port(required_i64(&resolved, "port")?)?, + pool.getattr("max_connections")?.extract::()?, + None, + )?; Ok(Ok(ValkeySemanticCacheConfig { similarity_threshold: backend.getattr("similarity_threshold")?.extract()?, index_name: backend.getattr("index_name")?.extract()?, - embedding_model: backend.getattr("embedding_model")?.extract()?, connection, })) } @@ -946,11 +1039,6 @@ fn class_is(value: &Bound<'_, PyAny>, module: &str, name: &str) -> PyResult) -> PyResult> { - value.extract::>()?.map(duration).transpose() -} - #[inline(never)] fn optional_attribute_string(value: &Bound<'_, PyAny>, name: &str) -> PyResult> { match value.getattr(name) { @@ -1070,16 +1158,17 @@ fn optional_dict_duration(values: &Bound<'_, PyDict>, key: &str) -> PyResult) -> NativeCacheConfig { + match NativeCacheConfig::project(facade).unwrap() { + CacheConfigProjection::Native(config) => *config, + CacheConfigProjection::Unsupported(reason) => panic!("{}", reason.message()), + } + } + + fn unsupported(facade: &Bound<'_, PyAny>) -> UnsupportedCacheConfig { + match NativeCacheConfig::project(facade).unwrap() { + CacheConfigProjection::Native(_) => panic!("configuration must stay on Python"), + CacheConfigProjection::Unsupported(reason) => reason, + } + } + + #[fixture] + fn interpreter() { Python::initialize(); + } + + #[fixture] + fn connection() -> RedisConnectionConfig { + RedisConnectionConfig { + host: "cache.internal".into(), + port: 6380, + database: 4, + username: None, + password: None, + protocol: RedisProtocol::Resp2, + pool_size: REDIS_PY_DEFAULT_MAX_CONNECTIONS, + read_timeout: None, + connect_timeout: None, + socket_keepalive: None, + health_check_interval: Duration::ZERO, + client_name: None, + tls: None, + } + } + + fn verified_tls() -> RedisTlsConfig { + RedisTlsConfig { + certificate_requirement: CertificateRequirement::Required, + check_hostname: true, + ca_certificate: None, + ca_data: None, + client_certificate: None, + client_key: None, + } + } + + #[rstest] + fn projects_effective_memory_configuration(_interpreter: ()) { Python::attach(|py| { let facade = facade( py, "backend = SimpleNamespace(default_ttl=913, max_size_in_memory=37, max_size_per_item=8)\n\ facade = SimpleNamespace(type='local', mode='default-on', ttl=11.5, namespace=None, supported_call_types=['completion'], redis_flush_size=None, semantic_cache_scope='key', cache=backend)", ); - let CacheConfigProjection::Native(config) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("memory cache should be supported"); - }; - assert_eq!( - config.policy.ttl.unwrap(), - std::time::Duration::from_secs_f64(11.5) - ); + let config = native(&facade); + assert_eq!(config.policy.semantic_cache_scope, "key"); + assert_eq!(config.policy.redis_flush_size, None); let CacheBackendConfig::Memory(memory) = config.backend else { panic!("expected memory configuration"); }; - assert_eq!(memory.default_ttl, std::time::Duration::from_secs(913)); + assert_eq!(memory.default_ttl, Duration::from_secs(913)); assert_eq!(memory.capacity, 37); assert_eq!(memory.max_entry_bytes, 8192); - let matching = - NativeResponseCache::memory(37, std::time::Duration::from_secs(913), 8192); - let mismatched = - NativeResponseCache::memory(37, std::time::Duration::from_secs(913), 8191); + let matching = NativeResponseCache::memory(37, Duration::from_secs(913), 8192); + let mismatched = NativeResponseCache::memory(37, Duration::from_secs(913), 8191); let matching_config = NativeCacheConfig { policy: config.policy, backend: CacheBackendConfig::Memory(memory), @@ -1154,9 +1283,8 @@ mod tests { }); } - #[test] - fn redis_semantic_service_mismatch_accepts_backend_precision_threshold() { - Python::initialize(); + #[rstest] + fn redis_semantic_service_mismatch_accepts_backend_precision_threshold(_interpreter: ()) { Python::attach(|py| { let facade = facade( py, @@ -1165,12 +1293,7 @@ mod tests { ); let backend = facade.getattr("cache").unwrap(); let embedder = PythonEmbedder::new(backend.clone().unbind()); - let CacheConfigProjection::Native(config) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("Redis semantic cache should be supported"); - }; - let CacheBackendConfig::RedisSemantic(config) = config.backend else { + let CacheBackendConfig::RedisSemantic(config) = native(&facade).backend else { panic!("expected Redis semantic configuration"); }; let service = NativeResponseCache::redis_semantic( @@ -1184,10 +1307,6 @@ mod tests { .unwrap(); let matching_config = NativeCacheConfig { policy: CachePolicy { - mode: "default-on".into(), - ttl: None, - namespace: None, - supported_call_types: None, redis_flush_size: None, semantic_cache_scope: "key".into(), }, @@ -1197,9 +1316,8 @@ mod tests { }); } - #[test] - fn projects_resolved_redis_tls_configuration() { - Python::initialize(); + #[rstest] + fn projects_resolved_redis_tls_configuration(_interpreter: ()) { Python::attach(|py| { let facade = facade( py, @@ -1211,15 +1329,12 @@ mod tests { backend = SimpleNamespace(default_ttl=777, namespace='team', redis_flush_size=31, redis_kwargs={}, redis_client=client)\n\ facade = SimpleNamespace(type='redis', mode='default-off', ttl=None, namespace='team', supported_call_types=None, redis_flush_size=31, semantic_cache_scope='key', cache=backend)", ); - let CacheConfigProjection::Native(config) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("Redis cache should be supported"); - }; + let config = native(&facade); + assert_eq!(config.policy.redis_flush_size, Some(31)); let CacheBackendConfig::Redis(redis) = config.backend else { panic!("expected Redis configuration"); }; - assert_eq!(redis.default_ttl, std::time::Duration::from_secs(777)); + assert_eq!(redis.default_ttl, Duration::from_secs(777)); assert_eq!(redis.namespace.as_deref(), Some("team")); assert_eq!(redis.flush_size, 31); assert_eq!(redis.connection.host, "cache.internal"); @@ -1227,7 +1342,21 @@ mod tests { assert_eq!(redis.connection.database, 4); assert_eq!(redis.connection.protocol, RedisProtocol::Resp3); assert_eq!(redis.connection.pool_size, 29); - let tls = redis.connection.tls.unwrap(); + assert_eq!( + redis.connection.read_timeout, + Some(Duration::from_secs_f64(7.5)) + ); + assert_eq!( + redis.connection.connect_timeout, + Some(Duration::from_secs(2)) + ); + assert_eq!(redis.connection.socket_keepalive, Some(true)); + assert_eq!( + redis.connection.health_check_interval, + Duration::from_secs(15) + ); + assert_eq!(redis.connection.client_name.as_deref(), Some("litellm")); + let tls = redis.connection.tls.as_ref().unwrap(); assert_eq!( tls.certificate_requirement, CertificateRequirement::Optional @@ -1237,123 +1366,89 @@ mod tests { assert_eq!(tls.ca_data.as_deref(), Some("CA DATA")); assert_eq!(tls.client_certificate.as_deref(), Some("/client.pem")); assert_eq!(tls.client_key.as_deref(), Some("/client.key")); + assert!(matches!( + redis.connection.native_url(), + Err(UnsupportedCacheConfig::RedisPoolSize) + )); }); } - #[test] - fn projects_valkey_semantic_configuration() { - Python::initialize(); + #[rstest] + fn projects_valkey_semantic_configuration(_interpreter: ()) { Python::attach(|py| { let facade = facade( py, "pool = ConnectionPool()\n\ pool.connection_class = Connection\n\ pool.max_connections = 12\n\ - pool.connection_kwargs = {'host': 'cache.internal', 'port': 6390, 'db': 2}\n\ + pool.connection_kwargs = {'host': 'cache.internal', 'port': 6390, 'db': 2, 'socket_timeout': 3}\n\ client = SimpleNamespace(connection_pool=pool)\n\ backend = SimpleNamespace(similarity_threshold=0.85, index_name='semantic_idx', embedding_model='text-embedding-3-small', sync_client=client)\n\ facade = SimpleNamespace(type='valkey-semantic', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", ); - let CacheConfigProjection::Native(config) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("Valkey semantic cache should be supported"); - }; - let CacheBackendConfig::ValkeySemantic(valkey) = config.backend else { + let CacheBackendConfig::ValkeySemantic(valkey) = native(&facade).backend else { panic!("expected Valkey semantic configuration"); }; assert_eq!(valkey.similarity_threshold, 0.85); assert_eq!(valkey.index_name, "semantic_idx"); - assert_eq!(valkey.embedding_model, "text-embedding-3-small"); assert_eq!(valkey.connection.host, "cache.internal"); assert_eq!(valkey.connection.port, 6390); assert_eq!(valkey.connection.database, 2); assert_eq!(valkey.connection.pool_size, 12); assert_eq!(valkey.connection.protocol, RedisProtocol::Resp2); + assert_eq!(valkey.connection.read_timeout, Some(Duration::from_secs(3))); assert!(valkey.connection.tls.is_none()); }); } - #[test] - fn valkey_semantic_tls_stays_on_python() { - Python::initialize(); + #[rstest] + #[case::valkey_tls( + "pool = ConnectionPool()\n\ + pool.connection_class = SSLConnection\n\ + pool.connection_kwargs = {'host': 'cache.internal', 'port': 6390}\n\ + client = SimpleNamespace(connection_pool=pool)\n\ + backend = SimpleNamespace(similarity_threshold=0.85, index_name='semantic_idx', embedding_model='text-embedding-3-small', sync_client=client)\n\ + facade = SimpleNamespace(type='valkey-semantic', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", + "native Valkey semantic cache does not support TLS connections" + )] + #[case::valkey_dynamic_auth( + "pool = ConnectionPool()\n\ + pool.connection_class = Connection\n\ + pool.connection_kwargs = {'host': 'cache.internal', 'port': 6390, 'credential_provider': object()}\n\ + client = SimpleNamespace(connection_pool=pool)\n\ + backend = SimpleNamespace(similarity_threshold=0.85, index_name='semantic_idx', embedding_model='text-embedding-3-small', sync_client=client)\n\ + facade = SimpleNamespace(type='valkey-semantic', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", + "native Redis credentials require Python" + )] + #[case::redis_dynamic_auth( + "backend = SimpleNamespace(redis_kwargs={'credential_provider': object()})\n\ + facade = SimpleNamespace(type='redis', mode='default-on', ttl=None, namespace=None, supported_call_types=[], redis_flush_size=None, semantic_cache_scope='key', cache=backend)", + "native Redis credentials require Python" + )] + #[case::gcs_without_bucket( + "backend = SimpleNamespace(bucket_name=None, key_prefix='', path_service_account=None)\n\ + facade = SimpleNamespace(type='gcs', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", + "native GCS cache requires a configured bucket name" + )] + fn configurations_that_stay_on_python( + _interpreter: (), + #[case] body: &str, + #[case] message: &str, + ) { Python::attach(|py| { - let facade = facade( - py, - "pool = ConnectionPool()\n\ - pool.connection_class = SSLConnection\n\ - pool.connection_kwargs = {'host': 'cache.internal', 'port': 6390}\n\ - client = SimpleNamespace(connection_pool=pool)\n\ - backend = SimpleNamespace(similarity_threshold=0.85, index_name='semantic_idx', embedding_model='text-embedding-3-small', sync_client=client)\n\ - facade = SimpleNamespace(type='valkey-semantic', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", - ); - let CacheConfigProjection::Unsupported(reason) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("TLS Valkey semantic cache should stay on Python"); - }; - assert_eq!( - reason.message(), - "native Redis connection type is not implemented" - ); + assert_eq!(unsupported(&facade(py, body)).message(), message); }); } - #[test] - fn valkey_semantic_dynamic_auth_stays_on_python() { - Python::initialize(); - Python::attach(|py| { - let facade = facade( - py, - "pool = ConnectionPool()\n\ - pool.connection_class = Connection\n\ - pool.connection_kwargs = {'host': 'cache.internal', 'port': 6390, 'credential_provider': object()}\n\ - client = SimpleNamespace(connection_pool=pool)\n\ - backend = SimpleNamespace(similarity_threshold=0.85, index_name='semantic_idx', embedding_model='text-embedding-3-small', sync_client=client)\n\ - facade = SimpleNamespace(type='valkey-semantic', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", - ); - let CacheConfigProjection::Unsupported(reason) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("dynamic Valkey authentication must stay on Python"); - }; - assert_eq!(reason.message(), "native Redis credentials require Python"); - }); - } - - #[test] - fn dynamic_redis_auth_stays_on_python() { - Python::initialize(); - Python::attach(|py| { - let facade = facade( - py, - "backend = SimpleNamespace(redis_kwargs={'credential_provider': object()})\n\ - facade = SimpleNamespace(type='redis', mode='default-on', ttl=None, namespace=None, supported_call_types=[], redis_flush_size=None, semantic_cache_scope='key', cache=backend)", - ); - let CacheConfigProjection::Unsupported(reason) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("dynamic authentication must stay on Python"); - }; - assert_eq!(reason.message(), "native Redis credentials require Python"); - }); - } - - #[test] - fn projects_cluster_startup_nodes_as_redis_topology() { - Python::initialize(); + #[rstest] + fn projects_cluster_startup_nodes_as_redis_topology(_interpreter: ()) { Python::attach(|py| { let facade = cluster_facade( py, "[{'host': 'node-a', 'port': 7000}, {'host': 'node-b', 'port': 7001}]", "client.on_connect", ); - let CacheConfigProjection::Native(config) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("cluster startup nodes should project natively"); - }; - let CacheBackendConfig::Redis(redis) = &config.backend else { + let CacheBackendConfig::Redis(redis) = native(&facade).backend else { panic!("expected Redis configuration"); }; let expected = RedisTopology::Cluster { @@ -1382,23 +1477,22 @@ mod tests { .certificate_requirement, CertificateRequirement::None ); + assert!(matches!( + redis.connection.native_url(), + Err(UnsupportedCacheConfig::RedisTlsVerification) + )); }); } - #[test] - fn projects_gcs_configuration() { - Python::initialize(); + #[rstest] + fn projects_gcs_configuration(_interpreter: ()) { Python::attach(|py| { let facade = facade( py, "backend = SimpleNamespace(bucket_name='bucket', key_prefix='cache/', path_service_account='credentials.json')\n\ facade = SimpleNamespace(type='gcs', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", ); - let CacheConfigProjection::Native(config) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("GCS cache should be supported"); - }; + let config = native(&facade); let CacheBackendConfig::Gcs(gcs) = config.backend else { panic!("expected GCS configuration"); }; @@ -1417,9 +1511,9 @@ mod tests { path_service_account: Some("credentials.json".into()), endpoint: litellm_cache_gcs::DEFAULT_ENDPOINT.into(), }, + reqwest::Client::new(), Some("token".into()), - ) - .unwrap(); + ); let matching_config = NativeCacheConfig { policy: config.policy, backend: CacheBackendConfig::Gcs(gcs), @@ -1428,62 +1522,194 @@ mod tests { }); } - #[test] - fn rejects_gcs_without_a_bucket_name() { - Python::initialize(); + #[rstest] + #[case::extra_node_field( + "[{'host': 'node-a', 'port': 7000, 'server_type': 'primary'}]", + "client.on_connect", + "native Redis topology is not implemented" + )] + #[case::non_numeric_port( + "[{'host': 'node-a', 'port': 'seven'}]", + "client.on_connect", + "native Redis topology is not implemented" + )] + #[case::empty("[]", "client.on_connect", "native Redis topology is not implemented")] + #[case::foreign_hook( + "[{'host': 'node-a', 'port': 7000}]", + "lambda connection: None", + "native Redis credentials require Python" + )] + fn malformed_startup_nodes_and_foreign_connect_hooks_stay_on_python( + _interpreter: (), + #[case] startup_nodes: &str, + #[case] hook: &str, + #[case] message: &str, + ) { Python::attach(|py| { - let facade = facade( - py, - "backend = SimpleNamespace(bucket_name=None, key_prefix='', path_service_account=None)\n\ - facade = SimpleNamespace(type='gcs', mode='default-on', ttl=None, namespace=None, supported_call_types=None, redis_flush_size=None, semantic_cache_scope='key', cache=backend)", - ); - let CacheConfigProjection::Unsupported(reason) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("GCS cache without a bucket should be unsupported"); - }; - assert!(matches!(&reason, UnsupportedCacheConfig::GcsBucket)); - assert_eq!( - reason.message(), - "native GCS cache requires a configured bucket name" - ); + let facade = cluster_facade(py, startup_nodes, hook); + assert_eq!(unsupported(&facade).message(), message); }); } - #[test] - fn malformed_startup_nodes_and_foreign_connect_hooks_stay_on_python() { - Python::initialize(); - Python::attach(|py| { - for (startup_nodes, hook, message) in [ - ( - "[{'host': 'node-a', 'port': 7000, 'server_type': 'primary'}]", - "client.on_connect", - "native Redis topology is not implemented", - ), - ( - "[{'host': 'node-a', 'port': 'seven'}]", - "client.on_connect", - "native Redis topology is not implemented", - ), - ( - "[]", - "client.on_connect", - "native Redis topology is not implemented", - ), - ( - "[{'host': 'node-a', 'port': 7000}]", - "lambda connection: None", - "native Redis credentials require Python", - ), - ] { - let facade = cluster_facade(py, startup_nodes, hook); - let CacheConfigProjection::Unsupported(reason) = - NativeCacheConfig::project(&facade).unwrap() - else { - panic!("{startup_nodes} with {hook} must stay on Python"); - }; - assert_eq!(reason.message(), message, "{startup_nodes} with {hook}"); + #[rstest] + #[case::plain(|_: &mut RedisConnectionConfig| {}, "redis://cache.internal:6380/4")] + #[case::credentials( + |connection: &mut RedisConnectionConfig| { + connection.username = Some("user".into()); + connection.password = Some("p@ss:word".into()); + }, + "redis://user:p%40ss%3Aword@cache.internal:6380/4" + )] + #[case::password_only( + |connection: &mut RedisConnectionConfig| connection.password = Some("secret".into()), + "redis://:secret@cache.internal:6380/4" + )] + #[case::resp3( + |connection: &mut RedisConnectionConfig| connection.protocol = RedisProtocol::Resp3, + "redis://cache.internal:6380/4?protocol=resp3" + )] + #[case::ipv6( + |connection: &mut RedisConnectionConfig| connection.host = "::1".into(), + "redis://[::1]:6380/4" + )] + #[case::verified_tls( + |connection: &mut RedisConnectionConfig| connection.tls = Some(verified_tls()), + "rediss://cache.internal:6380/4" + )] + #[case::optional_certificate( + |connection: &mut RedisConnectionConfig| { + connection.tls = Some(RedisTlsConfig { + certificate_requirement: CertificateRequirement::Optional, + ..verified_tls() + }); + }, + "rediss://cache.internal:6380/4" + )] + #[case::keepalive_off( + |connection: &mut RedisConnectionConfig| connection.socket_keepalive = Some(false), + "redis://cache.internal:6380/4" + )] + #[case::redis_cache_socket_timeout( + |connection: &mut RedisConnectionConfig| { + connection.read_timeout = Some(Duration::from_secs(5)); + }, + "redis://cache.internal:6380/4" + )] + fn native_url_encodes_the_resolved_connection( + mut connection: RedisConnectionConfig, + #[case] configure: fn(&mut RedisConnectionConfig), + #[case] expected: &str, + ) { + configure(&mut connection); + assert_eq!(connection.native_url().ok().as_deref(), Some(expected)); + } + + #[rstest] + #[case::pool_size( + |connection: &mut RedisConnectionConfig| connection.pool_size = 50, + "native Redis uses a fixed connection pool; max_connections requires Python" + )] + #[case::socket_timeout( + |connection: &mut RedisConnectionConfig| { + connection.read_timeout = Some(Duration::from_millis(100)); + }, + "native Redis uses fixed socket timeouts; socket_timeout and socket_connect_timeout require Python" + )] + #[case::connect_timeout( + |connection: &mut RedisConnectionConfig| { + connection.connect_timeout = Some(Duration::from_secs(1)); + }, + "native Redis uses fixed socket timeouts; socket_timeout and socket_connect_timeout require Python" + )] + #[case::keepalive( + |connection: &mut RedisConnectionConfig| connection.socket_keepalive = Some(true), + "native Redis does not support socket_keepalive" + )] + #[case::health_check( + |connection: &mut RedisConnectionConfig| { + connection.health_check_interval = Duration::from_secs(25); + }, + "native Redis does not support health_check_interval" + )] + #[case::client_name( + |connection: &mut RedisConnectionConfig| connection.client_name = Some("litellm".into()), + "native Redis does not support client_name" + )] + #[case::custom_ca( + |connection: &mut RedisConnectionConfig| { + connection.tls = Some(RedisTlsConfig { + ca_certificate: Some("/ca.pem".into()), + ..verified_tls() + }); + }, + "native Redis TLS does not support ssl_ca_certs, ssl_ca_data, ssl_certfile or ssl_keyfile" + )] + #[case::client_certificate( + |connection: &mut RedisConnectionConfig| { + connection.tls = Some(RedisTlsConfig { + client_certificate: Some("/client.pem".into()), + client_key: Some("/client.key".into()), + ..verified_tls() + }); + }, + "native Redis TLS does not support ssl_ca_certs, ssl_ca_data, ssl_certfile or ssl_keyfile" + )] + #[case::unverified( + |connection: &mut RedisConnectionConfig| { + connection.tls = Some(RedisTlsConfig { + certificate_requirement: CertificateRequirement::None, + check_hostname: false, + ..verified_tls() + }); + }, + "native Redis TLS always verifies the certificate and hostname; ssl_cert_reqs=none and ssl_check_hostname=false require Python" + )] + #[case::hostname_unchecked( + |connection: &mut RedisConnectionConfig| { + connection.tls = Some(RedisTlsConfig { + check_hostname: false, + ..verified_tls() + }); + }, + "native Redis TLS always verifies the certificate and hostname; ssl_cert_reqs=none and ssl_check_hostname=false require Python" + )] + fn native_url_declines_settings_the_native_client_cannot_honor( + mut connection: RedisConnectionConfig, + #[case] configure: fn(&mut RedisConnectionConfig), + #[case] message: &str, + ) { + configure(&mut connection); + let Err(reason) = connection.native_url() else { + panic!("{message}"); + }; + assert_eq!(reason.message(), message); + } + + #[rstest] + #[case::plain("redis://:secret@127.0.0.1:6379", true)] + #[case::database("redis://127.0.0.1:6379/2", true)] + #[case::unix("unix:///tmp/redis.sock", true)] + #[case::tls("rediss://cache.internal:6380", false)] + #[case::query_options("redis://127.0.0.1:6379?socket_timeout=1", false)] + #[case::malformed("not a url", false)] + fn redis_semantic_native_url_accepts_only_plain_urls(#[case] url: &str, #[case] native: bool) { + let config = RedisSemanticCacheConfig { + redis_url: url.into(), + index_name: "idx".into(), + similarity_threshold: 0.8, + }; + match config.native_url() { + Ok(value) => { + assert!(native, "{url} must decline"); + assert_eq!(value, url); } - }); + Err(reason) => { + assert!(!native, "{url} must be native"); + assert_eq!( + reason.message(), + "native Redis semantic cache does not support TLS or query options in redis_url" + ); + } + } } } diff --git a/litellm-rust/crates/python-bridge/src/cache/embedder.rs b/litellm-rust/crates/python-bridge/src/cache/embedder.rs index ffd72e33e1b..7eadd9bc4b0 100644 --- a/litellm-rust/crates/python-bridge/src/cache/embedder.rs +++ b/litellm-rust/crates/python-bridge/src/cache/embedder.rs @@ -90,21 +90,7 @@ impl PythonEmbedder { } } -impl litellm_cache_valkey_semantic::Embedder for PythonEmbedder { - fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { - self.embed_sync(prompt, metadata) - } - - fn async_embed( - &self, - _prompt: &str, - _metadata: Option<&Value>, - ) -> impl Future, Error>> + Send { - std::future::ready(Self::seeded_embedding()) - } -} - -impl litellm_cache_redis_semantic::Embedder for PythonEmbedder { +impl litellm_cache::semantic::Embedder for PythonEmbedder { fn embed(&self, prompt: &str, metadata: Option<&Value>) -> Result, Error> { self.embed_sync(prompt, metadata) } @@ -129,15 +115,14 @@ mod tests { let embedder = PythonEmbedder::new(object); let scoped_embedder = embedder.clone(); let scoped = with_prepared_embedding(Ok(vec![0.25]), async move { - litellm_cache_redis_semantic::Embedder::async_embed(&scoped_embedder, "prompt", None) - .await + litellm_cache::semantic::Embedder::async_embed(&scoped_embedder, "prompt", None).await }); assert_eq!(scoped.await, Ok(vec![0.25])); let unscoped = - litellm_cache_redis_semantic::Embedder::async_embed(&embedder, "prompt", None).await; + litellm_cache::semantic::Embedder::async_embed(&embedder, "prompt", None).await; assert_eq!(unscoped, Err(Error::Unavailable)); let valkey = with_prepared_embedding(Ok(vec![0.5]), async move { - litellm_cache_valkey_semantic::Embedder::async_embed(&embedder, "prompt", None).await + litellm_cache::semantic::Embedder::async_embed(&embedder, "prompt", None).await }); assert_eq!(valkey.await, Ok(vec![0.5])); } diff --git a/litellm-rust/crates/python-bridge/src/cache/facade.rs b/litellm-rust/crates/python-bridge/src/cache/facade.rs index 17fa278ae5e..88fde2f6de0 100644 --- a/litellm-rust/crates/python-bridge/src/cache/facade.rs +++ b/litellm-rust/crates/python-bridge/src/cache/facade.rs @@ -80,6 +80,10 @@ const CLUSTER_POOL: RedisPoolAttributes = RedisPoolAttributes { const VALKEY_POOL: RedisPoolAttributes = STANDALONE_POOL; +/// Class-level defaults an instance overwrites with its own state rather than behavior: +/// `Cache._native_cache` holds the runtime `Cache.__init__` resolved. +const INSTANCE_STATE: &[&str] = &["_native_cache"]; + pub(super) struct FacadeGuard { outer: ObjectGuard, backend: ObjectGuard, @@ -166,7 +170,9 @@ impl ObjectGuard { return Ok(false); } for (name, value) in &expected.attributes { - if (instance.contains(name)? && !self.config_names.contains(&name.as_str())) + if (instance.contains(name)? + && !self.config_names.contains(&name.as_str()) + && !INSTANCE_STATE.contains(&name.as_str())) || !attributes.get_item(name)?.is(value.bind(py)) { return Ok(false); diff --git a/litellm-rust/crates/python-bridge/src/cache/handle.rs b/litellm-rust/crates/python-bridge/src/cache/handle.rs index 61993f42279..dff8a771a23 100644 --- a/litellm-rust/crates/python-bridge/src/cache/handle.rs +++ b/litellm-rust/crates/python-bridge/src/cache/handle.rs @@ -10,7 +10,6 @@ use pyo3::{ PyTraverseError, PyVisit, exceptions::{PyRuntimeError, PyTypeError}, prelude::*, - types::PyDict, }; use url::Url; @@ -19,6 +18,7 @@ use super::{ config::{QdrantSemanticCacheConfig, project_redis_semantic}, embedder::PythonEmbedder, facade::FacadeGuard, + host_client, native::NativeResponseCache, request::duration, }; @@ -109,7 +109,10 @@ impl CacheTestHandle { ..Default::default() }, }; - let service = run_sync_value(py, async move { Ok(NativeResponseCache::s3(config).await) })?; + let http = host_client(py, ClientVariant::NoRedirect)?; + let service = run_sync_value(py, async move { + Ok(NativeResponseCache::s3(config, http).await) + })?; Ok(Self { service, guard: None, @@ -133,8 +136,8 @@ impl CacheTestHandle { path_service_account, endpoint: endpoint.unwrap_or_else(|| DEFAULT_ENDPOINT.to_string()), }; - let service = release_gil(py, move || NativeResponseCache::gcs(config, token)) - .map_err(cache_error)?; + let client = host_client(py, ClientVariant::NoRedirect)?; + let service = NativeResponseCache::gcs(config, client, token); Ok(Self { service, guard: None, @@ -236,10 +239,7 @@ impl CacheTestHandle { }, quantization, }; - let http_config = crate::http::call_config(py, &PyDict::new(py), true)?; - let client = crate::http::pool() - .client(&http_config, ClientVariant::Provider) - .map_err(crate::http::client_error)?; + let client = host_client(py, ClientVariant::Provider)?; let service = run_sync_value(py, async move { let handle = tokio::runtime::Handle::current(); NativeResponseCache::qdrant_semantic(config, client, handle) @@ -279,8 +279,9 @@ impl CacheTestHandle { #[staticmethod] #[pyo3(signature = (account_url, container))] fn azure_blob(py: Python<'_>, account_url: String, container: String) -> PyResult { + let http = host_client(py, ClientVariant::NoRedirect)?; let service = run_sync_value(py, async move { - NativeResponseCache::azure_blob(&account_url, &container) + NativeResponseCache::azure_blob(&account_url, &container, http) .await .map_err(cache_error) })?; diff --git a/litellm-rust/crates/python-bridge/src/cache/mod.rs b/litellm-rust/crates/python-bridge/src/cache/mod.rs index 28dd6c3e798..0cfd4ac8138 100644 --- a/litellm-rust/crates/python-bridge/src/cache/mod.rs +++ b/litellm-rust/crates/python-bridge/src/cache/mod.rs @@ -1,3 +1,4 @@ +mod activation; mod binding; mod callback; mod config; @@ -12,9 +13,11 @@ mod resolver; mod semantic; use litellm_cache::Error; +use litellm_http::ClientVariant; use pyo3::{ exceptions::{PyNotImplementedError, PyRuntimeError, PyValueError}, prelude::*, + types::PyDict, }; pub(crate) use self::{ @@ -28,3 +31,11 @@ fn cache_error(error: Error) -> PyErr { _ => PyRuntimeError::new_err(error.to_string()), } } + +/// The host's pooled HTTP client, configured from the proxy's HTTP settings. +fn host_client(py: Python<'_>, variant: ClientVariant) -> PyResult { + let http_config = crate::http::call_config(py, &PyDict::new(py), true)?; + crate::http::pool() + .client(&http_config, variant) + .map_err(crate::http::client_error) +} diff --git a/litellm-rust/crates/python-bridge/src/cache/native.rs b/litellm-rust/crates/python-bridge/src/cache/native.rs index 254b9cdea4d..3c260e70843 100644 --- a/litellm-rust/crates/python-bridge/src/cache/native.rs +++ b/litellm-rust/crates/python-bridge/src/cache/native.rs @@ -1,15 +1,16 @@ use std::{sync::Arc, time::Duration}; -use litellm_cache::{CacheCodec, CacheConnectionResult, Error}; +use litellm_cache::{CacheCodec, CacheConnectionResult, Error, semantic::SemanticLookup}; use litellm_cache_azure_blob::AzureBlobCache; use litellm_cache_disk::DiskCache; use litellm_cache_gcs::{GcsCache, GcsConfig, StaticTokenSource}; use litellm_cache_memory::InMemoryCache; -use litellm_cache_qdrant_semantic::{Embedder, OpenAiEmbedder, QdrantSemanticCache}; +use litellm_cache_qdrant_semantic::{OpenAiEmbedder, QdrantSemanticCache}; use litellm_cache_redis::{RedisCache, RedisTopology}; use litellm_cache_redis_semantic::{RedisSemanticCache, RedisSemanticConfig}; use litellm_cache_response::{ - ExactResponseCache, PartialHits, ResponseCache, ResponseCacheCodec, WriteBuffer, + ConnectionProbe, ExactResponseCache, PartialHits, ResponseCache, ResponseCacheCodec, + WriteBuffer, }; use litellm_cache_s3::{S3Cache, S3CacheConfig}; use litellm_cache_valkey_semantic::{ValkeySemanticCache, ValkeySemanticConfig}; @@ -33,20 +34,11 @@ pub(super) struct EmbeddingInput { /// An exact-match backend behind one pointer, with the identity its facade must reproduce. pub(super) struct ExactService { cache: Arc, + probe: Option>, buffer: Option, identity: BackendIdentity, } -impl ExactService { - fn new(cache: Arc, identity: BackendIdentity) -> Arc { - Arc::new(Self { - cache, - buffer: None, - identity, - }) - } -} - #[derive(Clone)] pub(super) enum NativeResponseCache { Exact(Arc), @@ -56,7 +48,7 @@ pub(super) enum NativeResponseCache { scope: String, }, RedisSemantic { - cache: Arc>>, + cache: Arc>>, embedder: PythonEmbedder, }, QdrantSemantic(Arc>>), @@ -94,12 +86,15 @@ impl NativeResponseCache { namespace: backend.namespace().map(str::to_owned), default_ttl: None, }; - Ok(Self::exact(ResponseCache::new(Arc::new(backend)), identity)) + Ok(Self::exact_probed( + ResponseCache::new(Arc::new(backend)), + identity, + )) } - pub async fn s3(config: S3CacheConfig) -> Self { + pub async fn s3(config: S3CacheConfig, http: reqwest::Client) -> Self { let runtime = tokio::runtime::Handle::current(); - let backend = S3Cache::new(config, ResponseCacheCodec, runtime); + let backend = S3Cache::new(config, http, ResponseCacheCodec, runtime); let identity = BackendIdentity::S3 { bucket: backend.bucket().to_owned(), key_prefix: backend.key_prefix().to_owned(), @@ -109,7 +104,7 @@ impl NativeResponseCache { Self::exact(ResponseCache::new(Arc::new(backend)), identity) } - pub fn disk(directory: &str) -> Result { + pub fn disk(directory: impl AsRef) -> Result { let backend = DiskCache::open(directory, ResponseCacheCodec)?; let identity = BackendIdentity::Disk { directory: backend.directory().to_path_buf(), @@ -117,27 +112,33 @@ impl NativeResponseCache { Ok(Self::exact(ResponseCache::new(Arc::new(backend)), identity)) } - pub fn gcs(config: GcsConfig, token: Option) -> Result { + pub fn gcs(config: GcsConfig, client: reqwest::Client, token: Option) -> Self { let backend = match token { Some(token) => GcsCache::with_token_source( config, + client, ResponseCacheCodec, Arc::new(StaticTokenSource(token)), - )?, - None => GcsCache::new(config, ResponseCacheCodec)?, + ), + None => GcsCache::new(config, client, ResponseCacheCodec), }; let identity = BackendIdentity::Gcs { bucket_name: backend.bucket_name().to_owned(), key_prefix: backend.key_prefix().to_owned(), path_service_account: backend.path_service_account().map(str::to_owned), }; - Ok(Self::exact(ResponseCache::new(Arc::new(backend)), identity)) + Self::exact(ResponseCache::new(Arc::new(backend)), identity) } - pub async fn azure_blob(account_url: &str, container: &str) -> Result { + pub async fn azure_blob( + account_url: &str, + container: &str, + http: reqwest::Client, + ) -> Result { let backend = AzureBlobCache::connect( account_url, container, + http, ResponseCacheCodec, tokio::runtime::Handle::current(), ) @@ -156,7 +157,25 @@ impl NativeResponseCache { B: litellm_cache::BaseCache, B::Context: Default + PartialEq, { - let cache: Arc = Arc::new(cache); + Self::exact_service(Arc::new(cache), None, identity) + } + + /// Wraps an exact backend whose Python class defines `test_connection`. + fn exact_probed(cache: ResponseCache, identity: BackendIdentity) -> Self + where + ResponseCache: ExactResponseCache + ConnectionProbe + 'static, + B: litellm_cache::BaseCache, + B::Context: Default + PartialEq, + { + let cache = Arc::new(cache); + Self::exact_service(cache.clone(), Some(cache), identity) + } + + fn exact_service( + cache: Arc, + probe: Option>, + identity: BackendIdentity, + ) -> Self { let default_ttl = cache.default_ttl(); let identity = match identity { BackendIdentity::Memory { @@ -179,7 +198,12 @@ impl NativeResponseCache { }, other => other, }; - Self::Exact(ExactService::new(cache, identity)) + Self::Exact(Arc::new(ExactService { + cache, + probe, + buffer: None, + identity, + })) } pub fn valkey_semantic( @@ -209,7 +233,7 @@ impl NativeResponseCache { embedder: PythonEmbedder, config: RedisSemanticConfig, ) -> Result { - let backend = RedisSemanticCache::new(url, embedder.clone(), config)?; + let backend = RedisSemanticCache::new(url, embedder.clone(), ResponseCacheCodec, config)?; Ok(Self::RedisSemantic { cache: Arc::new(ResponseCache::new(Arc::new(backend))), embedder, @@ -270,6 +294,7 @@ impl NativeResponseCache { Self::Exact(service) if matches!(service.identity, BackendIdentity::Redis { .. }) => { Self::Exact(Arc::new(ExactService { cache: Arc::clone(&service.cache), + probe: service.probe.clone(), buffer: flush_size.map(WriteBuffer::new), identity: service.identity.clone(), })) @@ -305,7 +330,7 @@ impl NativeResponseCache { Self::RedisSemantic { .. } => request.semantic().context, Self::Exact(_) | Self::QdrantSemantic(_) => return None, }; - let prompt = litellm_cache_redis_semantic::prompt_from_context(&context)?; + let prompt = litellm_cache::semantic::prompt_from_context(&context)?; Some(EmbeddingInput { prompt, metadata: context.metadata, @@ -344,6 +369,28 @@ impl NativeResponseCache { } } + /// `lookup` plus the similarity Python's semantic backend writes to the request metadata. + /// Exact backends report none. + pub fn lookup_semantic( + &self, + request: &NativeRequest, + now: Duration, + ) -> Result, Error> { + match self { + Self::Exact(service) => service + .cache + .lookup(&request.exact(), now) + .map(exact_lookup), + Self::ValkeySemantic { cache, scope, .. } => { + redis_family(cache.lookup_semantic(&request.scoped_semantic(scope), now)) + } + Self::RedisSemantic { cache, .. } => { + redis_family(cache.lookup_semantic(&request.semantic(), now)) + } + Self::QdrantSemantic(cache) => cache.lookup_semantic(&request.semantic(), now), + } + } + pub fn store( &self, request: &NativeRequest, @@ -390,6 +437,56 @@ impl NativeResponseCache { } } + pub async fn async_lookup_semantic( + &self, + request: &NativeRequest, + now: Duration, + ) -> Result, Error> { + match self { + Self::Exact(service) => service + .cache + .async_lookup(&request.exact(), now) + .await + .map(exact_lookup), + Self::ValkeySemantic { cache, scope, .. } => redis_family( + cache + .async_lookup_semantic(&request.scoped_semantic(scope), now) + .await, + ), + Self::RedisSemantic { cache, .. } => { + redis_family(cache.async_lookup_semantic(&request.semantic(), now).await) + } + Self::QdrantSemantic(cache) => { + cache.async_lookup_semantic(&request.semantic(), now).await + } + } + } + + pub(super) fn async_lookup_semantic_py<'py>( + &self, + py: Python<'py>, + request: NativeRequest, + ) -> PyResult> { + match self { + Self::Exact(_) | Self::QdrantSemantic(_) => { + let service = self.clone(); + litellm_host_python::run_async( + py, + async move { + service + .async_lookup_semantic(&request, now()) + .await + .map(SemanticReply::from) + }, + super::cache_error, + ) + } + Self::ValkeySemantic { .. } | Self::RedisSemantic { .. } => { + self.python_semantic(py, SemanticOperation::LookupSemantic(request)) + } + } + } + pub(super) fn async_lookup_py<'py>( &self, py: Python<'py>, @@ -550,9 +647,11 @@ impl NativeResponseCache { pub async fn test_connection(&self) -> Result { match self { - Self::Exact(service) => service.cache.test_connection().await, - Self::ValkeySemantic { cache, .. } => cache.test_connection().await, - Self::RedisSemantic { .. } | Self::QdrantSemantic(_) => { + Self::Exact(service) => match &service.probe { + Some(probe) => probe.test_connection().await, + None => Err(Error::UnsupportedOperation), + }, + Self::ValkeySemantic { .. } | Self::RedisSemantic { .. } | Self::QdrantSemantic(_) => { Err(Error::UnsupportedOperation) } } @@ -571,3 +670,27 @@ impl NativeResponseCache { fn exact_requests(requests: &[NativeRequest]) -> Vec { requests.iter().map(NativeRequest::exact).collect() } + +/// What `lookup_semantic` hands Python: the response and the similarity to stamp, if any. +#[derive(serde::Serialize)] +pub(super) struct SemanticReply(pub(super) Option, pub(super) Option); + +impl From> for SemanticReply { + fn from(lookup: SemanticLookup) -> Self { + Self(lookup.value, lookup.similarity) + } +} + +fn exact_lookup(value: Option) -> SemanticLookup { + SemanticLookup { + value, + similarity: None, + } +} + +/// Python's Redis and Valkey semantic caches catch every lookup failure and stamp `0.0`. +fn redis_family( + lookup: Result, Error>, +) -> Result, Error> { + Ok(lookup.unwrap_or_else(|_| SemanticLookup::miss(Some(0.0)))) +} diff --git a/litellm-rust/crates/python-bridge/src/cache/semantic.rs b/litellm-rust/crates/python-bridge/src/cache/semantic.rs index 9f4d18d45cd..bea58b88885 100644 --- a/litellm-rust/crates/python-bridge/src/cache/semantic.rs +++ b/litellm-rust/crates/python-bridge/src/cache/semantic.rs @@ -12,12 +12,14 @@ use serde_json::Value; use super::{ cache_error, embedder::{PythonEmbedder, with_prepared_embedding}, - native::NativeResponseCache, + native::{NativeResponseCache, SemanticReply}, request::{NativeRequest, now}, }; pub(super) enum SemanticOperation { Lookup(NativeRequest), + /// A lookup that also reports the similarity, as `SemanticReply`. + LookupSemantic(NativeRequest), Store(NativeRequest, Value), StoreBatch(VecDeque<(NativeRequest, Value)>), } @@ -71,7 +73,9 @@ impl SemanticExecution { /// Takes the next entry of the operation; `None` once a batch is exhausted. fn next_pending(&mut self) -> Option<(NativeRequest, Option)> { match &mut self.operation { - SemanticOperation::Lookup(request) => Some((request.clone(), None)), + SemanticOperation::Lookup(request) | SemanticOperation::LookupSemantic(request) => { + Some((request.clone(), None)) + } SemanticOperation::Store(request, response) => { Some((request.clone(), Some(std::mem::take(response)))) } @@ -109,7 +113,7 @@ impl SemanticExecution { Ok(vector) => { PythonEmbedder::extract(vector.into_bound(py)).map_err(|_| Error::Unavailable) } - Err(error) => match self.failure { + Err(error) => match self.embedding_failure() { EmbeddingFailure::Propagate => return Err(error), EmbeddingFailure::Unavailable if error.is_instance_of::(py) => { Err(Error::Unavailable) @@ -120,6 +124,14 @@ impl SemanticExecution { self.backend_step(py, seed) } + /// Python's semantic lookups catch embedding errors and stamp a similarity of `0.0`. + fn embedding_failure(&self) -> EmbeddingFailure { + match self.operation { + SemanticOperation::LookupSemantic(_) => EmbeddingFailure::Unavailable, + _ => self.failure, + } + } + fn backend_step( &mut self, py: Python<'_>, @@ -131,13 +143,18 @@ impl SemanticExecution { })?; let service = self.service.clone(); let now = self.now; + let with_similarity = matches!(self.operation, SemanticOperation::LookupSemantic(_)); let future = async move { match response { - None => service.async_lookup(&request, now).await, + None if with_similarity => service + .async_lookup_semantic(&request, now) + .await + .map(|lookup| Reply::Semantic(lookup.into())), + None => service.async_lookup(&request, now).await.map(Reply::Plain), Some(response) => service .async_store(&request, response, now) .await - .map(|_| None), + .map(|_| Reply::Plain(None)), } }; let awaitable = run_async(py, with_prepared_embedding(seed, future), cache_error)?; @@ -171,6 +188,13 @@ impl SemanticExecution { } } +#[derive(serde::Serialize)] +#[serde(untagged)] +enum Reply { + Plain(Option), + Semantic(SemanticReply), +} + impl ExecutionBody for SemanticExecution { fn resume(&mut self, result: Option>>) -> PyResult { Python::attach(|py| self.resume_py(py, result)) diff --git a/litellm-rust/crates/python-compat/AGENTS.md b/litellm-rust/crates/python-compat/AGENTS.md new file mode 100644 index 00000000000..0869568d33b --- /dev/null +++ b/litellm-rust/crates/python-compat/AGENTS.md @@ -0,0 +1,23 @@ +- Pure Python *data formats* in Rust, for state Python LiteLLM writes and Rust must read or write byte-compatibly + - No PyO3, no live objects: truthiness, `__str__`, descriptors of real Python objects belong to `python-bridge`'s coercion layer + - Format *choices* stay with callers: the `{timestamp, response}` envelope, diskcache modes, and the cache-key recipe live in the cache crates and only call into this crate +- Intended users + - `cache-response` codec: reading `str(dict)` values Python's sync Redis path writes (`literal_eval`) + - `cache-disk`: diskcache's pickled values (`pickle`), falsy-is-miss (`truthy`) + - Cache-key derivation: sha256 over `str(value)` must match Python byte for byte (`repr::to_str`) + - Byte-identical writes where Python compares raw values (`json::dumps`, `repr`) +- Relation to [`py_literal`](https://docs.rs/py_literal/latest/py_literal/): replaced, do not reintroduce + - Its pest grammar backtracks: parse time doubles per nested `[`/`{` (105 ms at depth 16); ours is linear (19 µs at depth 128) + - Its formatter is not `repr` (`2e-1`, always single quotes, escapes non-ASCII); it also lost `-0.0`, `(1+2j)`, `set()` + - `cache-response` and `cache-disk` still depend on it; migrate them here +- Relation to [`serde-pickle`](https://docs.rs/serde-pickle/latest/serde_pickle/): the pickle codec, used only through its serde interface + - Never `serde_pickle::Value`: its `BTreeMap` dicts reorder keys + - Accepted limits: ints beyond i64, `tuple`/`set`/`frozenset` decode as lists, class references (`GLOBAL`/`REDUCE`) fail; writes protocol 3 +- Every behavior is pinned by CPython output, not by reasoning; everything under `generated/` is script output, never hand-edited + - Regenerate `generated/values.json` with `scripts/generate_fixtures.py`; add a corpus row before changing behavior + - Divergences go in `KNOWN` in `tests/fixtures.rs` with a reason; an entry that starts matching fails until deleted + - Regenerate `generated/nonprintable.rs` with `scripts/generate_nonprintable.py` when the target Python's Unicode version changes + - `scripts/verify_rust_pickles.py` checks CPython reads Rust pickles, with class resolution disabled; CI does not run Python +- Decoders recurse, so they reject nesting beyond `MAX_DEPTH` for stack safety: deliberately stricter than CPython, whose parser takes ~200 levels and whose unpickler has no limit (pinned as `nested_150`) + - Formatters (`repr`, `json`) are unbounded; values from the decoders are already capped, a hand-built `Value` is the caller's responsibility + - `literal_eval` must stay linear in depth: `tests/limits.rs` times the deepest parse, `benches/formats.rs` measures the curve but is manual, since CI runs no Rust bench diff --git a/litellm-rust/crates/python-compat/Cargo.toml b/litellm-rust/crates/python-compat/Cargo.toml new file mode 100644 index 00000000000..2ab6fb29843 --- /dev/null +++ b/litellm-rust/crates/python-compat/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "litellm-python-compat" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Python data formats (repr, literal_eval, json.dumps, pickle) reproduced for interop with persisted LiteLLM state" + +[dependencies] +num-bigint = "0.4" +num-traits = "0.2" +serde.workspace = true +serde-pickle = "1.2" +serde_json = { workspace = true, features = ["preserve_order"] } +thiserror.workspace = true + +[dev-dependencies] +criterion.workspace = true +hex = "0.4" +rstest.workspace = true + +[[bench]] +name = "formats" +harness = false diff --git a/litellm-rust/crates/python-compat/benches/formats.rs b/litellm-rust/crates/python-compat/benches/formats.rs new file mode 100644 index 00000000000..f0b83cb1658 --- /dev/null +++ b/litellm-rust/crates/python-compat/benches/formats.rs @@ -0,0 +1,111 @@ +//! Throughput of each format on a cached chat completion, and `literal_eval` cost by nesting. +//! +//! Run one group with `cargo bench -p litellm-python-compat -- cached_completion`, and compare +//! against a stored run with `--save-baseline ` / `--baseline `. +//! +//! `literal_eval/nesting` guards against backtracking: the `py_literal` grammar this parser +//! replaced doubled its time per nested `[` or `{` (105 ms at depth 16), so cost must stay +//! linear in depth for every container shape. + +use std::{hint::black_box, time::Duration}; + +use criterion::{ + BatchSize, BenchmarkGroup, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main, + measurement::WallTime, +}; +use litellm_python_compat::{Value, json, literal::literal_eval, pickle, repr::repr}; + +/// `str(entry)` for the `{timestamp, response}` envelope Python's sync Redis path writes. +fn cached_completion() -> String { + let choices: Vec = (0..4) + .map(|index| { + format!( + "{{'finish_reason': 'stop', 'index': {index}, 'message': {{'content': \ + 'Benchmarks compare the same workload under controlled conditions, so a \ + change in time reflects the code rather than the environment. café 日本 \ + {index}', 'role': 'assistant', 'tool_calls': None, 'function_call': None}}, \ + 'logprobs': None}}" + ) + }) + .collect(); + format!( + "{{'timestamp': 1726000000.123, 'response': {{'id': 'chatcmpl-9x1', 'created': \ + 1726000000, 'model': 'gpt-4o-2024-08-06', 'object': 'chat.completion', \ + 'system_fingerprint': 'fp_1', 'choices': [{}], 'usage': {{'completion_tokens': 120, \ + 'prompt_tokens': 42, 'total_tokens': 162, 'completion_tokens_details': None}}}}}}", + choices.join(", ") + ) +} + +/// Every text format, measured against the source bytes it reads or writes. +fn text_formats(group: &mut BenchmarkGroup<'_, WallTime>, text: &str, value: &Value) { + group.throughput(Throughput::Bytes(text.len() as u64)); + group.bench_function("literal_eval", |bencher| { + bencher.iter(|| literal_eval(black_box(text))) + }); + group.bench_function("repr", |bencher| bencher.iter(|| repr(black_box(value)))); + group.bench_function("json_dumps", |bencher| { + bencher.iter(|| json::dumps(black_box(value))) + }); + group.bench_function("to_json", |bencher| { + bencher.iter(|| json::to_json(black_box(value))) + }); +} + +/// Pickle, measured against its own encoding rather than the source text. +fn binary_formats(group: &mut BenchmarkGroup<'_, WallTime>, value: &Value, pickled: &[u8]) { + group.throughput(Throughput::Bytes(pickled.len() as u64)); + group.bench_function("pickle_dumps", |bencher| { + bencher.iter(|| pickle::dumps(black_box(value))) + }); + group.bench_function("pickle_loads", |bencher| { + bencher.iter(|| pickle::loads(black_box(pickled))) + }); +} + +fn formats(c: &mut Criterion) { + let text = cached_completion(); + let value = literal_eval(&text).expect("benchmark payload is a literal"); + let pickled = pickle::dumps(&value).expect("benchmark payload pickles"); + let dumped = json::dumps(&value).expect("benchmark payload is JSON serializable"); + + let mut group = c.benchmark_group("cached_completion"); + text_formats(&mut group, &text, &value); + binary_formats(&mut group, &value, &pickled); + // `from_json` consumes its input, so each iteration gets a freshly parsed one. + group.throughput(Throughput::Bytes(dumped.len() as u64)); + group.bench_function("from_json", |bencher| { + bencher.iter_batched( + || serde_json::from_str::(&dumped).expect("dumps output parses"), + json::from_json, + BatchSize::SmallInput, + ) + }); + group.finish(); +} + +/// One nesting level of each container shape, as `(name, open, close)`. +const SHAPES: [(&str, &str, &str); 3] = [ + ("list", "[", "]"), + ("dict", "{'a': ", "}"), + ("tuple", "(", ",)"), +]; + +fn literal_nesting(c: &mut Criterion) { + let mut group = c.benchmark_group("literal_eval/nesting"); + group.sample_size(10); + group.measurement_time(Duration::from_secs(3)); + for depth in [4, 16, 64, 128] { + for (shape, open, close) in SHAPES { + let text = format!("{}1{}", open.repeat(depth), close.repeat(depth)); + group.throughput(Throughput::Bytes(text.len() as u64)); + group.bench_with_input(BenchmarkId::new(shape, depth), &text, |bencher, text| { + bencher.iter(|| literal_eval(black_box(text))) + }); + } + } + group.finish(); +} + +criterion_group!(benches, formats, literal_nesting); +criterion_main!(benches); diff --git a/litellm-rust/crates/python-compat/generated/nonprintable.rs b/litellm-rust/crates/python-compat/generated/nonprintable.rs new file mode 100644 index 00000000000..a044210f07d --- /dev/null +++ b/litellm-rust/crates/python-compat/generated/nonprintable.rs @@ -0,0 +1,745 @@ +// Generated by scripts/generate_nonprintable.py from Python 3.14.7 +// (Unicode 16.0.0). Do not edit by hand. + +pub(crate) const UNICODE_VERSION: &str = "16.0.0"; + +/// Inclusive code point ranges for which Python's `str.isprintable()` is false. +pub(crate) const NONPRINTABLE: [(u32, u32); 737] = [ + (0x0000, 0x001F), + (0x007F, 0x00A0), + (0x00AD, 0x00AD), + (0x0378, 0x0379), + (0x0380, 0x0383), + (0x038B, 0x038B), + (0x038D, 0x038D), + (0x03A2, 0x03A2), + (0x0530, 0x0530), + (0x0557, 0x0558), + (0x058B, 0x058C), + (0x0590, 0x0590), + (0x05C8, 0x05CF), + (0x05EB, 0x05EE), + (0x05F5, 0x0605), + (0x061C, 0x061C), + (0x06DD, 0x06DD), + (0x070E, 0x070F), + (0x074B, 0x074C), + (0x07B2, 0x07BF), + (0x07FB, 0x07FC), + (0x082E, 0x082F), + (0x083F, 0x083F), + (0x085C, 0x085D), + (0x085F, 0x085F), + (0x086B, 0x086F), + (0x088F, 0x0896), + (0x08E2, 0x08E2), + (0x0984, 0x0984), + (0x098D, 0x098E), + (0x0991, 0x0992), + (0x09A9, 0x09A9), + (0x09B1, 0x09B1), + (0x09B3, 0x09B5), + (0x09BA, 0x09BB), + (0x09C5, 0x09C6), + (0x09C9, 0x09CA), + (0x09CF, 0x09D6), + (0x09D8, 0x09DB), + (0x09DE, 0x09DE), + (0x09E4, 0x09E5), + (0x09FF, 0x0A00), + (0x0A04, 0x0A04), + (0x0A0B, 0x0A0E), + (0x0A11, 0x0A12), + (0x0A29, 0x0A29), + (0x0A31, 0x0A31), + (0x0A34, 0x0A34), + (0x0A37, 0x0A37), + (0x0A3A, 0x0A3B), + (0x0A3D, 0x0A3D), + (0x0A43, 0x0A46), + (0x0A49, 0x0A4A), + (0x0A4E, 0x0A50), + (0x0A52, 0x0A58), + (0x0A5D, 0x0A5D), + (0x0A5F, 0x0A65), + (0x0A77, 0x0A80), + (0x0A84, 0x0A84), + (0x0A8E, 0x0A8E), + (0x0A92, 0x0A92), + (0x0AA9, 0x0AA9), + (0x0AB1, 0x0AB1), + (0x0AB4, 0x0AB4), + (0x0ABA, 0x0ABB), + (0x0AC6, 0x0AC6), + (0x0ACA, 0x0ACA), + (0x0ACE, 0x0ACF), + (0x0AD1, 0x0ADF), + (0x0AE4, 0x0AE5), + (0x0AF2, 0x0AF8), + (0x0B00, 0x0B00), + (0x0B04, 0x0B04), + (0x0B0D, 0x0B0E), + (0x0B11, 0x0B12), + (0x0B29, 0x0B29), + (0x0B31, 0x0B31), + (0x0B34, 0x0B34), + (0x0B3A, 0x0B3B), + (0x0B45, 0x0B46), + (0x0B49, 0x0B4A), + (0x0B4E, 0x0B54), + (0x0B58, 0x0B5B), + (0x0B5E, 0x0B5E), + (0x0B64, 0x0B65), + (0x0B78, 0x0B81), + (0x0B84, 0x0B84), + (0x0B8B, 0x0B8D), + (0x0B91, 0x0B91), + (0x0B96, 0x0B98), + (0x0B9B, 0x0B9B), + (0x0B9D, 0x0B9D), + (0x0BA0, 0x0BA2), + (0x0BA5, 0x0BA7), + (0x0BAB, 0x0BAD), + (0x0BBA, 0x0BBD), + (0x0BC3, 0x0BC5), + (0x0BC9, 0x0BC9), + (0x0BCE, 0x0BCF), + (0x0BD1, 0x0BD6), + (0x0BD8, 0x0BE5), + (0x0BFB, 0x0BFF), + (0x0C0D, 0x0C0D), + (0x0C11, 0x0C11), + (0x0C29, 0x0C29), + (0x0C3A, 0x0C3B), + (0x0C45, 0x0C45), + (0x0C49, 0x0C49), + (0x0C4E, 0x0C54), + (0x0C57, 0x0C57), + (0x0C5B, 0x0C5C), + (0x0C5E, 0x0C5F), + (0x0C64, 0x0C65), + (0x0C70, 0x0C76), + (0x0C8D, 0x0C8D), + (0x0C91, 0x0C91), + (0x0CA9, 0x0CA9), + (0x0CB4, 0x0CB4), + (0x0CBA, 0x0CBB), + (0x0CC5, 0x0CC5), + (0x0CC9, 0x0CC9), + (0x0CCE, 0x0CD4), + (0x0CD7, 0x0CDC), + (0x0CDF, 0x0CDF), + (0x0CE4, 0x0CE5), + (0x0CF0, 0x0CF0), + (0x0CF4, 0x0CFF), + (0x0D0D, 0x0D0D), + (0x0D11, 0x0D11), + (0x0D45, 0x0D45), + (0x0D49, 0x0D49), + (0x0D50, 0x0D53), + (0x0D64, 0x0D65), + (0x0D80, 0x0D80), + (0x0D84, 0x0D84), + (0x0D97, 0x0D99), + (0x0DB2, 0x0DB2), + (0x0DBC, 0x0DBC), + (0x0DBE, 0x0DBF), + (0x0DC7, 0x0DC9), + (0x0DCB, 0x0DCE), + (0x0DD5, 0x0DD5), + (0x0DD7, 0x0DD7), + (0x0DE0, 0x0DE5), + (0x0DF0, 0x0DF1), + (0x0DF5, 0x0E00), + (0x0E3B, 0x0E3E), + (0x0E5C, 0x0E80), + (0x0E83, 0x0E83), + (0x0E85, 0x0E85), + (0x0E8B, 0x0E8B), + (0x0EA4, 0x0EA4), + (0x0EA6, 0x0EA6), + (0x0EBE, 0x0EBF), + (0x0EC5, 0x0EC5), + (0x0EC7, 0x0EC7), + (0x0ECF, 0x0ECF), + (0x0EDA, 0x0EDB), + (0x0EE0, 0x0EFF), + (0x0F48, 0x0F48), + (0x0F6D, 0x0F70), + (0x0F98, 0x0F98), + (0x0FBD, 0x0FBD), + (0x0FCD, 0x0FCD), + (0x0FDB, 0x0FFF), + (0x10C6, 0x10C6), + (0x10C8, 0x10CC), + (0x10CE, 0x10CF), + (0x1249, 0x1249), + (0x124E, 0x124F), + (0x1257, 0x1257), + (0x1259, 0x1259), + (0x125E, 0x125F), + (0x1289, 0x1289), + (0x128E, 0x128F), + (0x12B1, 0x12B1), + (0x12B6, 0x12B7), + (0x12BF, 0x12BF), + (0x12C1, 0x12C1), + (0x12C6, 0x12C7), + (0x12D7, 0x12D7), + (0x1311, 0x1311), + (0x1316, 0x1317), + (0x135B, 0x135C), + (0x137D, 0x137F), + (0x139A, 0x139F), + (0x13F6, 0x13F7), + (0x13FE, 0x13FF), + (0x1680, 0x1680), + (0x169D, 0x169F), + (0x16F9, 0x16FF), + (0x1716, 0x171E), + (0x1737, 0x173F), + (0x1754, 0x175F), + (0x176D, 0x176D), + (0x1771, 0x1771), + (0x1774, 0x177F), + (0x17DE, 0x17DF), + (0x17EA, 0x17EF), + (0x17FA, 0x17FF), + (0x180E, 0x180E), + (0x181A, 0x181F), + (0x1879, 0x187F), + (0x18AB, 0x18AF), + (0x18F6, 0x18FF), + (0x191F, 0x191F), + (0x192C, 0x192F), + (0x193C, 0x193F), + (0x1941, 0x1943), + (0x196E, 0x196F), + (0x1975, 0x197F), + (0x19AC, 0x19AF), + (0x19CA, 0x19CF), + (0x19DB, 0x19DD), + (0x1A1C, 0x1A1D), + (0x1A5F, 0x1A5F), + (0x1A7D, 0x1A7E), + (0x1A8A, 0x1A8F), + (0x1A9A, 0x1A9F), + (0x1AAE, 0x1AAF), + (0x1ACF, 0x1AFF), + (0x1B4D, 0x1B4D), + (0x1BF4, 0x1BFB), + (0x1C38, 0x1C3A), + (0x1C4A, 0x1C4C), + (0x1C8B, 0x1C8F), + (0x1CBB, 0x1CBC), + (0x1CC8, 0x1CCF), + (0x1CFB, 0x1CFF), + (0x1F16, 0x1F17), + (0x1F1E, 0x1F1F), + (0x1F46, 0x1F47), + (0x1F4E, 0x1F4F), + (0x1F58, 0x1F58), + (0x1F5A, 0x1F5A), + (0x1F5C, 0x1F5C), + (0x1F5E, 0x1F5E), + (0x1F7E, 0x1F7F), + (0x1FB5, 0x1FB5), + (0x1FC5, 0x1FC5), + (0x1FD4, 0x1FD5), + (0x1FDC, 0x1FDC), + (0x1FF0, 0x1FF1), + (0x1FF5, 0x1FF5), + (0x1FFF, 0x200F), + (0x2028, 0x202F), + (0x205F, 0x206F), + (0x2072, 0x2073), + (0x208F, 0x208F), + (0x209D, 0x209F), + (0x20C1, 0x20CF), + (0x20F1, 0x20FF), + (0x218C, 0x218F), + (0x242A, 0x243F), + (0x244B, 0x245F), + (0x2B74, 0x2B75), + (0x2B96, 0x2B96), + (0x2CF4, 0x2CF8), + (0x2D26, 0x2D26), + (0x2D28, 0x2D2C), + (0x2D2E, 0x2D2F), + (0x2D68, 0x2D6E), + (0x2D71, 0x2D7E), + (0x2D97, 0x2D9F), + (0x2DA7, 0x2DA7), + (0x2DAF, 0x2DAF), + (0x2DB7, 0x2DB7), + (0x2DBF, 0x2DBF), + (0x2DC7, 0x2DC7), + (0x2DCF, 0x2DCF), + (0x2DD7, 0x2DD7), + (0x2DDF, 0x2DDF), + (0x2E5E, 0x2E7F), + (0x2E9A, 0x2E9A), + (0x2EF4, 0x2EFF), + (0x2FD6, 0x2FEF), + (0x3000, 0x3000), + (0x3040, 0x3040), + (0x3097, 0x3098), + (0x3100, 0x3104), + (0x3130, 0x3130), + (0x318F, 0x318F), + (0x31E6, 0x31EE), + (0x321F, 0x321F), + (0xA48D, 0xA48F), + (0xA4C7, 0xA4CF), + (0xA62C, 0xA63F), + (0xA6F8, 0xA6FF), + (0xA7CE, 0xA7CF), + (0xA7D2, 0xA7D2), + (0xA7D4, 0xA7D4), + (0xA7DD, 0xA7F1), + (0xA82D, 0xA82F), + (0xA83A, 0xA83F), + (0xA878, 0xA87F), + (0xA8C6, 0xA8CD), + (0xA8DA, 0xA8DF), + (0xA954, 0xA95E), + (0xA97D, 0xA97F), + (0xA9CE, 0xA9CE), + (0xA9DA, 0xA9DD), + (0xA9FF, 0xA9FF), + (0xAA37, 0xAA3F), + (0xAA4E, 0xAA4F), + (0xAA5A, 0xAA5B), + (0xAAC3, 0xAADA), + (0xAAF7, 0xAB00), + (0xAB07, 0xAB08), + (0xAB0F, 0xAB10), + (0xAB17, 0xAB1F), + (0xAB27, 0xAB27), + (0xAB2F, 0xAB2F), + (0xAB6C, 0xAB6F), + (0xABEE, 0xABEF), + (0xABFA, 0xABFF), + (0xD7A4, 0xD7AF), + (0xD7C7, 0xD7CA), + (0xD7FC, 0xF8FF), + (0xFA6E, 0xFA6F), + (0xFADA, 0xFAFF), + (0xFB07, 0xFB12), + (0xFB18, 0xFB1C), + (0xFB37, 0xFB37), + (0xFB3D, 0xFB3D), + (0xFB3F, 0xFB3F), + (0xFB42, 0xFB42), + (0xFB45, 0xFB45), + (0xFBC3, 0xFBD2), + (0xFD90, 0xFD91), + (0xFDC8, 0xFDCE), + (0xFDD0, 0xFDEF), + (0xFE1A, 0xFE1F), + (0xFE53, 0xFE53), + (0xFE67, 0xFE67), + (0xFE6C, 0xFE6F), + (0xFE75, 0xFE75), + (0xFEFD, 0xFF00), + (0xFFBF, 0xFFC1), + (0xFFC8, 0xFFC9), + (0xFFD0, 0xFFD1), + (0xFFD8, 0xFFD9), + (0xFFDD, 0xFFDF), + (0xFFE7, 0xFFE7), + (0xFFEF, 0xFFFB), + (0xFFFE, 0xFFFF), + (0x1000C, 0x1000C), + (0x10027, 0x10027), + (0x1003B, 0x1003B), + (0x1003E, 0x1003E), + (0x1004E, 0x1004F), + (0x1005E, 0x1007F), + (0x100FB, 0x100FF), + (0x10103, 0x10106), + (0x10134, 0x10136), + (0x1018F, 0x1018F), + (0x1019D, 0x1019F), + (0x101A1, 0x101CF), + (0x101FE, 0x1027F), + (0x1029D, 0x1029F), + (0x102D1, 0x102DF), + (0x102FC, 0x102FF), + (0x10324, 0x1032C), + (0x1034B, 0x1034F), + (0x1037B, 0x1037F), + (0x1039E, 0x1039E), + (0x103C4, 0x103C7), + (0x103D6, 0x103FF), + (0x1049E, 0x1049F), + (0x104AA, 0x104AF), + (0x104D4, 0x104D7), + (0x104FC, 0x104FF), + (0x10528, 0x1052F), + (0x10564, 0x1056E), + (0x1057B, 0x1057B), + (0x1058B, 0x1058B), + (0x10593, 0x10593), + (0x10596, 0x10596), + (0x105A2, 0x105A2), + (0x105B2, 0x105B2), + (0x105BA, 0x105BA), + (0x105BD, 0x105BF), + (0x105F4, 0x105FF), + (0x10737, 0x1073F), + (0x10756, 0x1075F), + (0x10768, 0x1077F), + (0x10786, 0x10786), + (0x107B1, 0x107B1), + (0x107BB, 0x107FF), + (0x10806, 0x10807), + (0x10809, 0x10809), + (0x10836, 0x10836), + (0x10839, 0x1083B), + (0x1083D, 0x1083E), + (0x10856, 0x10856), + (0x1089F, 0x108A6), + (0x108B0, 0x108DF), + (0x108F3, 0x108F3), + (0x108F6, 0x108FA), + (0x1091C, 0x1091E), + (0x1093A, 0x1093E), + (0x10940, 0x1097F), + (0x109B8, 0x109BB), + (0x109D0, 0x109D1), + (0x10A04, 0x10A04), + (0x10A07, 0x10A0B), + (0x10A14, 0x10A14), + (0x10A18, 0x10A18), + (0x10A36, 0x10A37), + (0x10A3B, 0x10A3E), + (0x10A49, 0x10A4F), + (0x10A59, 0x10A5F), + (0x10AA0, 0x10ABF), + (0x10AE7, 0x10AEA), + (0x10AF7, 0x10AFF), + (0x10B36, 0x10B38), + (0x10B56, 0x10B57), + (0x10B73, 0x10B77), + (0x10B92, 0x10B98), + (0x10B9D, 0x10BA8), + (0x10BB0, 0x10BFF), + (0x10C49, 0x10C7F), + (0x10CB3, 0x10CBF), + (0x10CF3, 0x10CF9), + (0x10D28, 0x10D2F), + (0x10D3A, 0x10D3F), + (0x10D66, 0x10D68), + (0x10D86, 0x10D8D), + (0x10D90, 0x10E5F), + (0x10E7F, 0x10E7F), + (0x10EAA, 0x10EAA), + (0x10EAE, 0x10EAF), + (0x10EB2, 0x10EC1), + (0x10EC5, 0x10EFB), + (0x10F28, 0x10F2F), + (0x10F5A, 0x10F6F), + (0x10F8A, 0x10FAF), + (0x10FCC, 0x10FDF), + (0x10FF7, 0x10FFF), + (0x1104E, 0x11051), + (0x11076, 0x1107E), + (0x110BD, 0x110BD), + (0x110C3, 0x110CF), + (0x110E9, 0x110EF), + (0x110FA, 0x110FF), + (0x11135, 0x11135), + (0x11148, 0x1114F), + (0x11177, 0x1117F), + (0x111E0, 0x111E0), + (0x111F5, 0x111FF), + (0x11212, 0x11212), + (0x11242, 0x1127F), + (0x11287, 0x11287), + (0x11289, 0x11289), + (0x1128E, 0x1128E), + (0x1129E, 0x1129E), + (0x112AA, 0x112AF), + (0x112EB, 0x112EF), + (0x112FA, 0x112FF), + (0x11304, 0x11304), + (0x1130D, 0x1130E), + (0x11311, 0x11312), + (0x11329, 0x11329), + (0x11331, 0x11331), + (0x11334, 0x11334), + (0x1133A, 0x1133A), + (0x11345, 0x11346), + (0x11349, 0x1134A), + (0x1134E, 0x1134F), + (0x11351, 0x11356), + (0x11358, 0x1135C), + (0x11364, 0x11365), + (0x1136D, 0x1136F), + (0x11375, 0x1137F), + (0x1138A, 0x1138A), + (0x1138C, 0x1138D), + (0x1138F, 0x1138F), + (0x113B6, 0x113B6), + (0x113C1, 0x113C1), + (0x113C3, 0x113C4), + (0x113C6, 0x113C6), + (0x113CB, 0x113CB), + (0x113D6, 0x113D6), + (0x113D9, 0x113E0), + (0x113E3, 0x113FF), + (0x1145C, 0x1145C), + (0x11462, 0x1147F), + (0x114C8, 0x114CF), + (0x114DA, 0x1157F), + (0x115B6, 0x115B7), + (0x115DE, 0x115FF), + (0x11645, 0x1164F), + (0x1165A, 0x1165F), + (0x1166D, 0x1167F), + (0x116BA, 0x116BF), + (0x116CA, 0x116CF), + (0x116E4, 0x116FF), + (0x1171B, 0x1171C), + (0x1172C, 0x1172F), + (0x11747, 0x117FF), + (0x1183C, 0x1189F), + (0x118F3, 0x118FE), + (0x11907, 0x11908), + (0x1190A, 0x1190B), + (0x11914, 0x11914), + (0x11917, 0x11917), + (0x11936, 0x11936), + (0x11939, 0x1193A), + (0x11947, 0x1194F), + (0x1195A, 0x1199F), + (0x119A8, 0x119A9), + (0x119D8, 0x119D9), + (0x119E5, 0x119FF), + (0x11A48, 0x11A4F), + (0x11AA3, 0x11AAF), + (0x11AF9, 0x11AFF), + (0x11B0A, 0x11BBF), + (0x11BE2, 0x11BEF), + (0x11BFA, 0x11BFF), + (0x11C09, 0x11C09), + (0x11C37, 0x11C37), + (0x11C46, 0x11C4F), + (0x11C6D, 0x11C6F), + (0x11C90, 0x11C91), + (0x11CA8, 0x11CA8), + (0x11CB7, 0x11CFF), + (0x11D07, 0x11D07), + (0x11D0A, 0x11D0A), + (0x11D37, 0x11D39), + (0x11D3B, 0x11D3B), + (0x11D3E, 0x11D3E), + (0x11D48, 0x11D4F), + (0x11D5A, 0x11D5F), + (0x11D66, 0x11D66), + (0x11D69, 0x11D69), + (0x11D8F, 0x11D8F), + (0x11D92, 0x11D92), + (0x11D99, 0x11D9F), + (0x11DAA, 0x11EDF), + (0x11EF9, 0x11EFF), + (0x11F11, 0x11F11), + (0x11F3B, 0x11F3D), + (0x11F5B, 0x11FAF), + (0x11FB1, 0x11FBF), + (0x11FF2, 0x11FFE), + (0x1239A, 0x123FF), + (0x1246F, 0x1246F), + (0x12475, 0x1247F), + (0x12544, 0x12F8F), + (0x12FF3, 0x12FFF), + (0x13430, 0x1343F), + (0x13456, 0x1345F), + (0x143FB, 0x143FF), + (0x14647, 0x160FF), + (0x1613A, 0x167FF), + (0x16A39, 0x16A3F), + (0x16A5F, 0x16A5F), + (0x16A6A, 0x16A6D), + (0x16ABF, 0x16ABF), + (0x16ACA, 0x16ACF), + (0x16AEE, 0x16AEF), + (0x16AF6, 0x16AFF), + (0x16B46, 0x16B4F), + (0x16B5A, 0x16B5A), + (0x16B62, 0x16B62), + (0x16B78, 0x16B7C), + (0x16B90, 0x16D3F), + (0x16D7A, 0x16E3F), + (0x16E9B, 0x16EFF), + (0x16F4B, 0x16F4E), + (0x16F88, 0x16F8E), + (0x16FA0, 0x16FDF), + (0x16FE5, 0x16FEF), + (0x16FF2, 0x16FFF), + (0x187F8, 0x187FF), + (0x18CD6, 0x18CFE), + (0x18D09, 0x1AFEF), + (0x1AFF4, 0x1AFF4), + (0x1AFFC, 0x1AFFC), + (0x1AFFF, 0x1AFFF), + (0x1B123, 0x1B131), + (0x1B133, 0x1B14F), + (0x1B153, 0x1B154), + (0x1B156, 0x1B163), + (0x1B168, 0x1B16F), + (0x1B2FC, 0x1BBFF), + (0x1BC6B, 0x1BC6F), + (0x1BC7D, 0x1BC7F), + (0x1BC89, 0x1BC8F), + (0x1BC9A, 0x1BC9B), + (0x1BCA0, 0x1CBFF), + (0x1CCFA, 0x1CCFF), + (0x1CEB4, 0x1CEFF), + (0x1CF2E, 0x1CF2F), + (0x1CF47, 0x1CF4F), + (0x1CFC4, 0x1CFFF), + (0x1D0F6, 0x1D0FF), + (0x1D127, 0x1D128), + (0x1D173, 0x1D17A), + (0x1D1EB, 0x1D1FF), + (0x1D246, 0x1D2BF), + (0x1D2D4, 0x1D2DF), + (0x1D2F4, 0x1D2FF), + (0x1D357, 0x1D35F), + (0x1D379, 0x1D3FF), + (0x1D455, 0x1D455), + (0x1D49D, 0x1D49D), + (0x1D4A0, 0x1D4A1), + (0x1D4A3, 0x1D4A4), + (0x1D4A7, 0x1D4A8), + (0x1D4AD, 0x1D4AD), + (0x1D4BA, 0x1D4BA), + (0x1D4BC, 0x1D4BC), + (0x1D4C4, 0x1D4C4), + (0x1D506, 0x1D506), + (0x1D50B, 0x1D50C), + (0x1D515, 0x1D515), + (0x1D51D, 0x1D51D), + (0x1D53A, 0x1D53A), + (0x1D53F, 0x1D53F), + (0x1D545, 0x1D545), + (0x1D547, 0x1D549), + (0x1D551, 0x1D551), + (0x1D6A6, 0x1D6A7), + (0x1D7CC, 0x1D7CD), + (0x1DA8C, 0x1DA9A), + (0x1DAA0, 0x1DAA0), + (0x1DAB0, 0x1DEFF), + (0x1DF1F, 0x1DF24), + (0x1DF2B, 0x1DFFF), + (0x1E007, 0x1E007), + (0x1E019, 0x1E01A), + (0x1E022, 0x1E022), + (0x1E025, 0x1E025), + (0x1E02B, 0x1E02F), + (0x1E06E, 0x1E08E), + (0x1E090, 0x1E0FF), + (0x1E12D, 0x1E12F), + (0x1E13E, 0x1E13F), + (0x1E14A, 0x1E14D), + (0x1E150, 0x1E28F), + (0x1E2AF, 0x1E2BF), + (0x1E2FA, 0x1E2FE), + (0x1E300, 0x1E4CF), + (0x1E4FA, 0x1E5CF), + (0x1E5FB, 0x1E5FE), + (0x1E600, 0x1E7DF), + (0x1E7E7, 0x1E7E7), + (0x1E7EC, 0x1E7EC), + (0x1E7EF, 0x1E7EF), + (0x1E7FF, 0x1E7FF), + (0x1E8C5, 0x1E8C6), + (0x1E8D7, 0x1E8FF), + (0x1E94C, 0x1E94F), + (0x1E95A, 0x1E95D), + (0x1E960, 0x1EC70), + (0x1ECB5, 0x1ED00), + (0x1ED3E, 0x1EDFF), + (0x1EE04, 0x1EE04), + (0x1EE20, 0x1EE20), + (0x1EE23, 0x1EE23), + (0x1EE25, 0x1EE26), + (0x1EE28, 0x1EE28), + (0x1EE33, 0x1EE33), + (0x1EE38, 0x1EE38), + (0x1EE3A, 0x1EE3A), + (0x1EE3C, 0x1EE41), + (0x1EE43, 0x1EE46), + (0x1EE48, 0x1EE48), + (0x1EE4A, 0x1EE4A), + (0x1EE4C, 0x1EE4C), + (0x1EE50, 0x1EE50), + (0x1EE53, 0x1EE53), + (0x1EE55, 0x1EE56), + (0x1EE58, 0x1EE58), + (0x1EE5A, 0x1EE5A), + (0x1EE5C, 0x1EE5C), + (0x1EE5E, 0x1EE5E), + (0x1EE60, 0x1EE60), + (0x1EE63, 0x1EE63), + (0x1EE65, 0x1EE66), + (0x1EE6B, 0x1EE6B), + (0x1EE73, 0x1EE73), + (0x1EE78, 0x1EE78), + (0x1EE7D, 0x1EE7D), + (0x1EE7F, 0x1EE7F), + (0x1EE8A, 0x1EE8A), + (0x1EE9C, 0x1EEA0), + (0x1EEA4, 0x1EEA4), + (0x1EEAA, 0x1EEAA), + (0x1EEBC, 0x1EEEF), + (0x1EEF2, 0x1EFFF), + (0x1F02C, 0x1F02F), + (0x1F094, 0x1F09F), + (0x1F0AF, 0x1F0B0), + (0x1F0C0, 0x1F0C0), + (0x1F0D0, 0x1F0D0), + (0x1F0F6, 0x1F0FF), + (0x1F1AE, 0x1F1E5), + (0x1F203, 0x1F20F), + (0x1F23C, 0x1F23F), + (0x1F249, 0x1F24F), + (0x1F252, 0x1F25F), + (0x1F266, 0x1F2FF), + (0x1F6D8, 0x1F6DB), + (0x1F6ED, 0x1F6EF), + (0x1F6FD, 0x1F6FF), + (0x1F777, 0x1F77A), + (0x1F7DA, 0x1F7DF), + (0x1F7EC, 0x1F7EF), + (0x1F7F1, 0x1F7FF), + (0x1F80C, 0x1F80F), + (0x1F848, 0x1F84F), + (0x1F85A, 0x1F85F), + (0x1F888, 0x1F88F), + (0x1F8AE, 0x1F8AF), + (0x1F8BC, 0x1F8BF), + (0x1F8C2, 0x1F8FF), + (0x1FA54, 0x1FA5F), + (0x1FA6E, 0x1FA6F), + (0x1FA7D, 0x1FA7F), + (0x1FA8A, 0x1FA8E), + (0x1FAC7, 0x1FACD), + (0x1FADD, 0x1FADE), + (0x1FAEA, 0x1FAEF), + (0x1FAF9, 0x1FAFF), + (0x1FB93, 0x1FB93), + (0x1FBFA, 0x1FFFF), + (0x2A6E0, 0x2A6FF), + (0x2B73A, 0x2B73F), + (0x2B81E, 0x2B81F), + (0x2CEA2, 0x2CEAF), + (0x2EBE1, 0x2EBEF), + (0x2EE5E, 0x2F7FF), + (0x2FA1E, 0x2FFFF), + (0x3134B, 0x3134F), + (0x323B0, 0xE00FF), + (0xE01F0, 0x10FFFF), +]; diff --git a/litellm-rust/crates/python-compat/generated/values.json b/litellm-rust/crates/python-compat/generated/values.json new file mode 100644 index 00000000000..7f7e1e35a7d --- /dev/null +++ b/litellm-rust/crates/python-compat/generated/values.json @@ -0,0 +1,2164 @@ +{ + "python": "3.14.7", + "rows": [ + { + "name": "None", + "source": "None", + "literal": true, + "plain": true, + "repr": "None", + "str": "None", + "truthy": false, + "json": "null", + "pickle": { + "0": "4e2e", + "1": "4e2e", + "2": "80024e2e", + "3": "80034e2e", + "4": "80044e2e", + "5": "80054e2e" + }, + "view": "None" + }, + { + "name": "True", + "source": "True", + "literal": true, + "plain": true, + "repr": "True", + "str": "True", + "truthy": true, + "json": "true", + "pickle": { + "0": "4930310a2e", + "1": "4930310a2e", + "2": "8002882e", + "3": "8003882e", + "4": "8004882e", + "5": "8005882e" + }, + "view": "True" + }, + { + "name": "False", + "source": "False", + "literal": true, + "plain": true, + "repr": "False", + "str": "False", + "truthy": false, + "json": "false", + "pickle": { + "0": "4930300a2e", + "1": "4930300a2e", + "2": "8002892e", + "3": "8003892e", + "4": "8004892e", + "5": "8005892e" + }, + "view": "False" + }, + { + "name": "0", + "source": "0", + "literal": true, + "plain": true, + "repr": "0", + "str": "0", + "truthy": false, + "json": "0", + "pickle": { + "0": "49300a2e", + "1": "4b002e", + "2": "80024b002e", + "3": "80034b002e", + "4": "80044b002e", + "5": "80054b002e" + }, + "view": "0" + }, + { + "name": "-7", + "source": "-7", + "literal": true, + "plain": true, + "repr": "-7", + "str": "-7", + "truthy": true, + "json": "-7", + "pickle": { + "0": "492d370a2e", + "1": "4af9ffffff2e", + "2": "80024af9ffffff2e", + "3": "80034af9ffffff2e", + "4": "80049506000000000000004af9ffffff2e", + "5": "80059506000000000000004af9ffffff2e" + }, + "view": "-7" + }, + { + "name": "2**63 - 1", + "source": "2**63 - 1", + "literal": true, + "plain": true, + "repr": "9223372036854775807", + "str": "9223372036854775807", + "truthy": true, + "json": "9223372036854775807", + "pickle": { + "0": "4c393232333337323033363835343737353830374c0a2e", + "1": "4c393232333337323033363835343737353830374c0a2e", + "2": "80028a08ffffffffffffff7f2e", + "3": "80038a08ffffffffffffff7f2e", + "4": "8004950b000000000000008a08ffffffffffffff7f2e", + "5": "8005950b000000000000008a08ffffffffffffff7f2e" + }, + "view": "9223372036854775807" + }, + { + "name": "-(2**63)", + "source": "-(2**63)", + "literal": true, + "plain": true, + "repr": "-9223372036854775808", + "str": "-9223372036854775808", + "truthy": true, + "json": "-9223372036854775808", + "pickle": { + "0": "4c2d393232333337323033363835343737353830384c0a2e", + "1": "4c2d393232333337323033363835343737353830384c0a2e", + "2": "80028a0800000000000000802e", + "3": "80038a0800000000000000802e", + "4": "8004950b000000000000008a0800000000000000802e", + "5": "8005950b000000000000008a0800000000000000802e" + }, + "view": "-9223372036854775808" + }, + { + "name": "2**64", + "source": "2**64", + "literal": true, + "plain": true, + "repr": "18446744073709551616", + "str": "18446744073709551616", + "truthy": true, + "json": "18446744073709551616", + "pickle": { + "0": "4c31383434363734343037333730393535313631364c0a2e", + "1": "4c31383434363734343037333730393535313631364c0a2e", + "2": "80028a090000000000000000012e", + "3": "80038a090000000000000000012e", + "4": "8004950c000000000000008a090000000000000000012e", + "5": "8005950c000000000000008a090000000000000000012e" + }, + "view": "18446744073709551616" + }, + { + "name": "-(2**70)", + "source": "-(2**70)", + "literal": true, + "plain": true, + "repr": "-1180591620717411303424", + "str": "-1180591620717411303424", + "truthy": true, + "json": "-1180591620717411303424", + "pickle": { + "0": "4c2d313138303539313632303731373431313330333432344c0a2e", + "1": "4c2d313138303539313632303731373431313330333432344c0a2e", + "2": "80028a090000000000000000c02e", + "3": "80038a090000000000000000c02e", + "4": "8004950c000000000000008a090000000000000000c02e", + "5": "8005950c000000000000008a090000000000000000c02e" + }, + "view": "-1180591620717411303424" + }, + { + "name": "0.0", + "source": "0.0", + "literal": true, + "plain": true, + "repr": "0.0", + "str": "0.0", + "truthy": false, + "json": "0.0", + "pickle": { + "0": "46302e300a2e", + "1": "4700000000000000002e", + "2": "80024700000000000000002e", + "3": "80034700000000000000002e", + "4": "8004950a000000000000004700000000000000002e", + "5": "8005950a000000000000004700000000000000002e" + }, + "view": "0.0" + }, + { + "name": "-0.0", + "source": "-0.0", + "literal": true, + "plain": true, + "repr": "-0.0", + "str": "-0.0", + "truthy": false, + "json": "-0.0", + "pickle": { + "0": "462d302e300a2e", + "1": "4780000000000000002e", + "2": "80024780000000000000002e", + "3": "80034780000000000000002e", + "4": "8004950a000000000000004780000000000000002e", + "5": "8005950a000000000000004780000000000000002e" + }, + "view": "-0.0" + }, + { + "name": "0.2", + "source": "0.2", + "literal": true, + "plain": true, + "repr": "0.2", + "str": "0.2", + "truthy": true, + "json": "0.2", + "pickle": { + "0": "46302e320a2e", + "1": "473fc999999999999a2e", + "2": "8002473fc999999999999a2e", + "3": "8003473fc999999999999a2e", + "4": "8004950a00000000000000473fc999999999999a2e", + "5": "8005950a00000000000000473fc999999999999a2e" + }, + "view": "0.2" + }, + { + "name": "1.0", + "source": "1.0", + "literal": true, + "plain": true, + "repr": "1.0", + "str": "1.0", + "truthy": true, + "json": "1.0", + "pickle": { + "0": "46312e300a2e", + "1": "473ff00000000000002e", + "2": "8002473ff00000000000002e", + "3": "8003473ff00000000000002e", + "4": "8004950a00000000000000473ff00000000000002e", + "5": "8005950a00000000000000473ff00000000000002e" + }, + "view": "1.0" + }, + { + "name": "-1.5", + "source": "-1.5", + "literal": true, + "plain": true, + "repr": "-1.5", + "str": "-1.5", + "truthy": true, + "json": "-1.5", + "pickle": { + "0": "462d312e350a2e", + "1": "47bff80000000000002e", + "2": "800247bff80000000000002e", + "3": "800347bff80000000000002e", + "4": "8004950a0000000000000047bff80000000000002e", + "5": "8005950a0000000000000047bff80000000000002e" + }, + "view": "-1.5" + }, + { + "name": "0.1 + 0.2", + "source": "0.1 + 0.2", + "literal": true, + "plain": true, + "repr": "0.30000000000000004", + "str": "0.30000000000000004", + "truthy": true, + "json": "0.30000000000000004", + "pickle": { + "0": "46302e33303030303030303030303030303030340a2e", + "1": "473fd33333333333342e", + "2": "8002473fd33333333333342e", + "3": "8003473fd33333333333342e", + "4": "8004950a00000000000000473fd33333333333342e", + "5": "8005950a00000000000000473fd33333333333342e" + }, + "view": "0.30000000000000004" + }, + { + "name": "123456789.123", + "source": "123456789.123", + "literal": true, + "plain": true, + "repr": "123456789.123", + "str": "123456789.123", + "truthy": true, + "json": "123456789.123", + "pickle": { + "0": "463132333435363738392e3132330a2e", + "1": "47419d6f34547df3b62e", + "2": "800247419d6f34547df3b62e", + "3": "800347419d6f34547df3b62e", + "4": "8004950a0000000000000047419d6f34547df3b62e", + "5": "8005950a0000000000000047419d6f34547df3b62e" + }, + "view": "123456789.123" + }, + { + "name": "1e15", + "source": "1e15", + "literal": true, + "plain": true, + "repr": "1000000000000000.0", + "str": "1000000000000000.0", + "truthy": true, + "json": "1000000000000000.0", + "pickle": { + "0": "46313030303030303030303030303030302e300a2e", + "1": "47430c6bf5263400002e", + "2": "800247430c6bf5263400002e", + "3": "800347430c6bf5263400002e", + "4": "8004950a0000000000000047430c6bf5263400002e", + "5": "8005950a0000000000000047430c6bf5263400002e" + }, + "view": "1000000000000000.0" + }, + { + "name": "1e16", + "source": "1e16", + "literal": true, + "plain": true, + "repr": "1e+16", + "str": "1e+16", + "truthy": true, + "json": "1e+16", + "pickle": { + "0": "4631652b31360a2e", + "1": "474341c37937e080002e", + "2": "8002474341c37937e080002e", + "3": "8003474341c37937e080002e", + "4": "8004950a00000000000000474341c37937e080002e", + "5": "8005950a00000000000000474341c37937e080002e" + }, + "view": "1e+16" + }, + { + "name": "1.5e16", + "source": "1.5e16", + "literal": true, + "plain": true, + "repr": "1.5e+16", + "str": "1.5e+16", + "truthy": true, + "json": "1.5e+16", + "pickle": { + "0": "46312e35652b31360a2e", + "1": "47434aa535d3d0c0002e", + "2": "800247434aa535d3d0c0002e", + "3": "800347434aa535d3d0c0002e", + "4": "8004950a0000000000000047434aa535d3d0c0002e", + "5": "8005950a0000000000000047434aa535d3d0c0002e" + }, + "view": "1.5e+16" + }, + { + "name": "9999999999999998.0", + "source": "9999999999999998.0", + "literal": true, + "plain": true, + "repr": "9999999999999998.0", + "str": "9999999999999998.0", + "truthy": true, + "json": "9999999999999998.0", + "pickle": { + "0": "46393939393939393939393939393939382e300a2e", + "1": "474341c37937e07fff2e", + "2": "8002474341c37937e07fff2e", + "3": "8003474341c37937e07fff2e", + "4": "8004950a00000000000000474341c37937e07fff2e", + "5": "8005950a00000000000000474341c37937e07fff2e" + }, + "view": "9999999999999998.0" + }, + { + "name": "0.0001", + "source": "0.0001", + "literal": true, + "plain": true, + "repr": "0.0001", + "str": "0.0001", + "truthy": true, + "json": "0.0001", + "pickle": { + "0": "46302e303030310a2e", + "1": "473f1a36e2eb1c432d2e", + "2": "8002473f1a36e2eb1c432d2e", + "3": "8003473f1a36e2eb1c432d2e", + "4": "8004950a00000000000000473f1a36e2eb1c432d2e", + "5": "8005950a00000000000000473f1a36e2eb1c432d2e" + }, + "view": "0.0001" + }, + { + "name": "1e-05", + "source": "1e-05", + "literal": true, + "plain": true, + "repr": "1e-05", + "str": "1e-05", + "truthy": true, + "json": "1e-05", + "pickle": { + "0": "4631652d30350a2e", + "1": "473ee4f8b588e368f12e", + "2": "8002473ee4f8b588e368f12e", + "3": "8003473ee4f8b588e368f12e", + "4": "8004950a00000000000000473ee4f8b588e368f12e", + "5": "8005950a00000000000000473ee4f8b588e368f12e" + }, + "view": "1e-05" + }, + { + "name": "1.25e-07", + "source": "1.25e-07", + "literal": true, + "plain": true, + "repr": "1.25e-07", + "str": "1.25e-07", + "truthy": true, + "json": "1.25e-07", + "pickle": { + "0": "46312e3235652d30370a2e", + "1": "473e80c6f7a0b5ed8d2e", + "2": "8002473e80c6f7a0b5ed8d2e", + "3": "8003473e80c6f7a0b5ed8d2e", + "4": "8004950a00000000000000473e80c6f7a0b5ed8d2e", + "5": "8005950a00000000000000473e80c6f7a0b5ed8d2e" + }, + "view": "1.25e-07" + }, + { + "name": "5e-324", + "source": "5e-324", + "literal": true, + "plain": true, + "repr": "5e-324", + "str": "5e-324", + "truthy": true, + "json": "5e-324", + "pickle": { + "0": "4635652d3332340a2e", + "1": "4700000000000000012e", + "2": "80024700000000000000012e", + "3": "80034700000000000000012e", + "4": "8004950a000000000000004700000000000000012e", + "5": "8005950a000000000000004700000000000000012e" + }, + "view": "5e-324" + }, + { + "name": "1.7976931348623157e308", + "source": "1.7976931348623157e308", + "literal": true, + "plain": true, + "repr": "1.7976931348623157e+308", + "str": "1.7976931348623157e+308", + "truthy": true, + "json": "1.7976931348623157e+308", + "pickle": { + "0": "46312e37393736393331333438363233313537652b3330380a2e", + "1": "477fefffffffffffff2e", + "2": "8002477fefffffffffffff2e", + "3": "8003477fefffffffffffff2e", + "4": "8004950a00000000000000477fefffffffffffff2e", + "5": "8005950a00000000000000477fefffffffffffff2e" + }, + "view": "1.7976931348623157e+308" + }, + { + "name": "1e22", + "source": "1e22", + "literal": true, + "plain": true, + "repr": "1e+22", + "str": "1e+22", + "truthy": true, + "json": "1e+22", + "pickle": { + "0": "4631652b32320a2e", + "1": "474480f0cf064dd5922e", + "2": "8002474480f0cf064dd5922e", + "3": "8003474480f0cf064dd5922e", + "4": "8004950a00000000000000474480f0cf064dd5922e", + "5": "8005950a00000000000000474480f0cf064dd5922e" + }, + "view": "1e+22" + }, + { + "name": "float('inf')", + "source": "float('inf')", + "literal": false, + "plain": true, + "repr": "inf", + "str": "inf", + "truthy": true, + "json": "Infinity", + "pickle": { + "0": "46696e660a2e", + "1": "477ff00000000000002e", + "2": "8002477ff00000000000002e", + "3": "8003477ff00000000000002e", + "4": "8004950a00000000000000477ff00000000000002e", + "5": "8005950a00000000000000477ff00000000000002e" + }, + "view": "inf" + }, + { + "name": "float('-inf')", + "source": "float('-inf')", + "literal": false, + "plain": true, + "repr": "-inf", + "str": "-inf", + "truthy": true, + "json": "-Infinity", + "pickle": { + "0": "462d696e660a2e", + "1": "47fff00000000000002e", + "2": "800247fff00000000000002e", + "3": "800347fff00000000000002e", + "4": "8004950a0000000000000047fff00000000000002e", + "5": "8005950a0000000000000047fff00000000000002e" + }, + "view": "-inf" + }, + { + "name": "float('nan')", + "source": "float('nan')", + "literal": false, + "plain": true, + "repr": "nan", + "str": "nan", + "truthy": true, + "json": "NaN", + "pickle": { + "0": "466e616e0a2e", + "1": "477ff80000000000002e", + "2": "8002477ff80000000000002e", + "3": "8003477ff80000000000002e", + "4": "8004950a00000000000000477ff80000000000002e", + "5": "8005950a00000000000000477ff80000000000002e" + }, + "view": "nan" + }, + { + "name": "1j", + "source": "1j", + "literal": true, + "plain": false, + "repr": "1j", + "str": "1j", + "truthy": true, + "json_error": "Object of type complex is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a636f6d706c65780a70300a2846302e300a46312e300a7470310a5270320a2e", + "1": "635f5f6275696c74696e5f5f0a636f6d706c65780a710028470000000000000000473ff00000000000007471015271022e", + "2": "8002635f5f6275696c74696e5f5f0a636f6d706c65780a7100470000000000000000473ff00000000000008671015271022e", + "3": "8003636275696c74696e730a636f6d706c65780a7100470000000000000000473ff00000000000008671015271022e", + "4": "8004952e000000000000008c086275696c74696e73948c07636f6d706c6578949394470000000000000000473ff0000000000000869452942e", + "5": "8005952e000000000000008c086275696c74696e73948c07636f6d706c6578949394470000000000000000473ff0000000000000869452942e" + }, + "view": "1j" + }, + { + "name": "-1j", + "source": "-1j", + "literal": false, + "plain": false, + "repr": "(-0-1j)", + "str": "(-0-1j)", + "truthy": true, + "json_error": "Object of type complex is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a636f6d706c65780a70300a28462d302e300a462d312e300a7470310a5270320a2e", + "1": "635f5f6275696c74696e5f5f0a636f6d706c65780a71002847800000000000000047bff00000000000007471015271022e", + "2": "8002635f5f6275696c74696e5f5f0a636f6d706c65780a710047800000000000000047bff00000000000008671015271022e", + "3": "8003636275696c74696e730a636f6d706c65780a710047800000000000000047bff00000000000008671015271022e", + "4": "8004952e000000000000008c086275696c74696e73948c07636f6d706c657894939447800000000000000047bff0000000000000869452942e", + "5": "8005952e000000000000008c086275696c74696e73948c07636f6d706c657894939447800000000000000047bff0000000000000869452942e" + }, + "view": "(-0-1j)" + }, + { + "name": "complex(0, -1)", + "source": "complex(0, -1)", + "literal": false, + "plain": false, + "repr": "-1j", + "str": "-1j", + "truthy": true, + "json_error": "Object of type complex is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a636f6d706c65780a70300a2846302e300a462d312e300a7470310a5270320a2e", + "1": "635f5f6275696c74696e5f5f0a636f6d706c65780a71002847000000000000000047bff00000000000007471015271022e", + "2": "8002635f5f6275696c74696e5f5f0a636f6d706c65780a710047000000000000000047bff00000000000008671015271022e", + "3": "8003636275696c74696e730a636f6d706c65780a710047000000000000000047bff00000000000008671015271022e", + "4": "8004952e000000000000008c086275696c74696e73948c07636f6d706c657894939447000000000000000047bff0000000000000869452942e", + "5": "8005952e000000000000008c086275696c74696e73948c07636f6d706c657894939447000000000000000047bff0000000000000869452942e" + }, + "view": "-1j" + }, + { + "name": "1+2j", + "source": "1+2j", + "literal": true, + "plain": false, + "repr": "(1+2j)", + "str": "(1+2j)", + "truthy": true, + "json_error": "Object of type complex is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a636f6d706c65780a70300a2846312e300a46322e300a7470310a5270320a2e", + "1": "635f5f6275696c74696e5f5f0a636f6d706c65780a710028473ff00000000000004740000000000000007471015271022e", + "2": "8002635f5f6275696c74696e5f5f0a636f6d706c65780a7100473ff00000000000004740000000000000008671015271022e", + "3": "8003636275696c74696e730a636f6d706c65780a7100473ff00000000000004740000000000000008671015271022e", + "4": "8004952e000000000000008c086275696c74696e73948c07636f6d706c6578949394473ff0000000000000474000000000000000869452942e", + "5": "8005952e000000000000008c086275696c74696e73948c07636f6d706c6578949394473ff0000000000000474000000000000000869452942e" + }, + "view": "(1+2j)" + }, + { + "name": "-1.5-0.5j", + "source": "-1.5-0.5j", + "literal": true, + "plain": false, + "repr": "(-1.5-0.5j)", + "str": "(-1.5-0.5j)", + "truthy": true, + "json_error": "Object of type complex is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a636f6d706c65780a70300a28462d312e350a462d302e350a7470310a5270320a2e", + "1": "635f5f6275696c74696e5f5f0a636f6d706c65780a71002847bff800000000000047bfe00000000000007471015271022e", + "2": "8002635f5f6275696c74696e5f5f0a636f6d706c65780a710047bff800000000000047bfe00000000000008671015271022e", + "3": "8003636275696c74696e730a636f6d706c65780a710047bff800000000000047bfe00000000000008671015271022e", + "4": "8004952e000000000000008c086275696c74696e73948c07636f6d706c657894939447bff800000000000047bfe0000000000000869452942e", + "5": "8005952e000000000000008c086275696c74696e73948c07636f6d706c657894939447bff800000000000047bfe0000000000000869452942e" + }, + "view": "(-1.5-0.5j)" + }, + { + "name": "complex(0.0, 1e16)", + "source": "complex(0.0, 1e16)", + "literal": true, + "plain": false, + "repr": "1e+16j", + "str": "1e+16j", + "truthy": true, + "json_error": "Object of type complex is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a636f6d706c65780a70300a2846302e300a4631652b31360a7470310a5270320a2e", + "1": "635f5f6275696c74696e5f5f0a636f6d706c65780a710028470000000000000000474341c37937e080007471015271022e", + "2": "8002635f5f6275696c74696e5f5f0a636f6d706c65780a7100470000000000000000474341c37937e080008671015271022e", + "3": "8003636275696c74696e730a636f6d706c65780a7100470000000000000000474341c37937e080008671015271022e", + "4": "8004952e000000000000008c086275696c74696e73948c07636f6d706c6578949394470000000000000000474341c37937e08000869452942e", + "5": "8005952e000000000000008c086275696c74696e73948c07636f6d706c6578949394470000000000000000474341c37937e08000869452942e" + }, + "view": "1e+16j" + }, + { + "name": "''", + "source": "''", + "literal": true, + "plain": true, + "repr": "''", + "str": "", + "truthy": false, + "json": "\"\"", + "pickle": { + "0": "560a70300a2e", + "1": "580000000071002e", + "2": "8002580000000071002e", + "3": "8003580000000071002e", + "4": "80049504000000000000008c00942e", + "5": "80059504000000000000008c00942e" + }, + "view": "''" + }, + { + "name": "'plain'", + "source": "'plain'", + "literal": true, + "plain": true, + "repr": "'plain'", + "str": "plain", + "truthy": true, + "json": "\"plain\"", + "pickle": { + "0": "56706c61696e0a70300a2e", + "1": "5805000000706c61696e71002e", + "2": "80025805000000706c61696e71002e", + "3": "80035805000000706c61696e71002e", + "4": "80049509000000000000008c05706c61696e942e", + "5": "80059509000000000000008c05706c61696e942e" + }, + "view": "'plain'" + }, + { + "name": "\"it's\"", + "source": "\"it's\"", + "literal": true, + "plain": true, + "repr": "\"it's\"", + "str": "it's", + "truthy": true, + "json": "\"it's\"", + "pickle": { + "0": "56697427730a70300a2e", + "1": "58040000006974277371002e", + "2": "800258040000006974277371002e", + "3": "800358040000006974277371002e", + "4": "80049508000000000000008c0469742773942e", + "5": "80059508000000000000008c0469742773942e" + }, + "view": "\"it's\"" + }, + { + "name": "'say \"hi\"'", + "source": "'say \"hi\"'", + "literal": true, + "plain": true, + "repr": "'say \"hi\"'", + "str": "say \"hi\"", + "truthy": true, + "json": "\"say \\\"hi\\\"\"", + "pickle": { + "0": "5673617920226869220a70300a2e", + "1": "5808000000736179202268692271002e", + "2": "80025808000000736179202268692271002e", + "3": "80035808000000736179202268692271002e", + "4": "8004950c000000000000008c087361792022686922942e", + "5": "8005950c000000000000008c087361792022686922942e" + }, + "view": "'say \"hi\"'" + }, + { + "name": "'both \\' and \"'", + "source": "'both \\' and \"'", + "literal": true, + "plain": true, + "repr": "'both \\' and \"'", + "str": "both ' and \"", + "truthy": true, + "json": "\"both ' and \\\"\"", + "pickle": { + "0": "56626f7468202720616e6420220a70300a2e", + "1": "580c000000626f7468202720616e64202271002e", + "2": "8002580c000000626f7468202720616e64202271002e", + "3": "8003580c000000626f7468202720616e64202271002e", + "4": "80049510000000000000008c0c626f7468202720616e642022942e", + "5": "80059510000000000000008c0c626f7468202720616e642022942e" + }, + "view": "'both \\' and \"'" + }, + { + "name": "'back\\\\slash'", + "source": "'back\\\\slash'", + "literal": true, + "plain": true, + "repr": "'back\\\\slash'", + "str": "back\\slash", + "truthy": true, + "json": "\"back\\\\slash\"", + "pickle": { + "0": "566261636b5c7530303563736c6173680a70300a2e", + "1": "580a0000006261636b5c736c61736871002e", + "2": "8002580a0000006261636b5c736c61736871002e", + "3": "8003580a0000006261636b5c736c61736871002e", + "4": "8004950e000000000000008c0a6261636b5c736c617368942e", + "5": "8005950e000000000000008c0a6261636b5c736c617368942e" + }, + "view": "'back\\\\slash'" + }, + { + "name": "'\\t\\n\\r'", + "source": "'\\t\\n\\r'", + "literal": true, + "plain": true, + "repr": "'\\t\\n\\r'", + "str": "\t\n\r", + "truthy": true, + "json": "\"\\t\\n\\r\"", + "pickle": { + "0": "56095c75303030615c75303030640a70300a2e", + "1": "5803000000090a0d71002e", + "2": "80025803000000090a0d71002e", + "3": "80035803000000090a0d71002e", + "4": "80049507000000000000008c03090a0d942e", + "5": "80059507000000000000008c03090a0d942e" + }, + "view": "'\\t\\n\\r'" + }, + { + "name": "'\\x00\\x1f\\x7f'", + "source": "'\\x00\\x1f\\x7f'", + "literal": true, + "plain": true, + "repr": "'\\x00\\x1f\\x7f'", + "str": "\u0000\u001f\u007f", + "truthy": true, + "json": "\"\\u0000\\u001f\\u007f\"", + "pickle": { + "0": "565c75303030301f7f0a70300a2e", + "1": "5803000000001f7f71002e", + "2": "80025803000000001f7f71002e", + "3": "80035803000000001f7f71002e", + "4": "80049507000000000000008c03001f7f942e", + "5": "80059507000000000000008c03001f7f942e" + }, + "view": "'\\x00\\x1f\\x7f'" + }, + { + "name": "'\\x85\\xa0\\xad'", + "source": "'\\x85\\xa0\\xad'", + "literal": true, + "plain": true, + "repr": "'\\x85\\xa0\\xad'", + "str": "\u0085\u00a0\u00ad", + "truthy": true, + "json": "\"\\u0085\\u00a0\\u00ad\"", + "pickle": { + "0": "5685a0ad0a70300a2e", + "1": "5806000000c285c2a0c2ad71002e", + "2": "80025806000000c285c2a0c2ad71002e", + "3": "80035806000000c285c2a0c2ad71002e", + "4": "8004950a000000000000008c06c285c2a0c2ad942e", + "5": "8005950a000000000000008c06c285c2a0c2ad942e" + }, + "view": "'\\x85\\xa0\\xad'" + }, + { + "name": "'caf\\xe9'", + "source": "'caf\\xe9'", + "literal": true, + "plain": true, + "repr": "'caf\u00e9'", + "str": "caf\u00e9", + "truthy": true, + "json": "\"caf\\u00e9\"", + "pickle": { + "0": "56636166e90a70300a2e", + "1": "5805000000636166c3a971002e", + "2": "80025805000000636166c3a971002e", + "3": "80035805000000636166c3a971002e", + "4": "80049509000000000000008c05636166c3a9942e", + "5": "80059509000000000000008c05636166c3a9942e" + }, + "view": "'caf\u00e9'" + }, + { + "name": "'\\u65e5\\u672c'", + "source": "'\\u65e5\\u672c'", + "literal": true, + "plain": true, + "repr": "'\u65e5\u672c'", + "str": "\u65e5\u672c", + "truthy": true, + "json": "\"\\u65e5\\u672c\"", + "pickle": { + "0": "565c75363565355c75363732630a70300a2e", + "1": "5806000000e697a5e69cac71002e", + "2": "80025806000000e697a5e69cac71002e", + "3": "80035806000000e697a5e69cac71002e", + "4": "8004950a000000000000008c06e697a5e69cac942e", + "5": "8005950a000000000000008c06e697a5e69cac942e" + }, + "view": "'\u65e5\u672c'" + }, + { + "name": "'\\u200b\\u2028\\u3000'", + "source": "'\\u200b\\u2028\\u3000'", + "literal": true, + "plain": true, + "repr": "'\\u200b\\u2028\\u3000'", + "str": "\u200b\u2028\u3000", + "truthy": true, + "json": "\"\\u200b\\u2028\\u3000\"", + "pickle": { + "0": "565c75323030625c75323032385c75333030300a70300a2e", + "1": "5809000000e2808be280a8e3808071002e", + "2": "80025809000000e2808be280a8e3808071002e", + "3": "80035809000000e2808be280a8e3808071002e", + "4": "8004950d000000000000008c09e2808be280a8e38080942e", + "5": "8005950d000000000000008c09e2808be280a8e38080942e" + }, + "view": "'\\u200b\\u2028\\u3000'" + }, + { + "name": "'\\U0001f600'", + "source": "'\\U0001f600'", + "literal": true, + "plain": true, + "repr": "'\ud83d\ude00'", + "str": "\ud83d\ude00", + "truthy": true, + "json": "\"\\ud83d\\ude00\"", + "pickle": { + "0": "565c5530303031663630300a70300a2e", + "1": "5804000000f09f988071002e", + "2": "80025804000000f09f988071002e", + "3": "80035804000000f09f988071002e", + "4": "80049508000000000000008c04f09f9880942e", + "5": "80059508000000000000008c04f09f9880942e" + }, + "view": "'\ud83d\ude00'" + }, + { + "name": "'\\U000e0001\\U0010ffff'", + "source": "'\\U000e0001\\U0010ffff'", + "literal": true, + "plain": true, + "repr": "'\\U000e0001\\U0010ffff'", + "str": "\udb40\udc01\udbff\udfff", + "truthy": true, + "json": "\"\\udb40\\udc01\\udbff\\udfff\"", + "pickle": { + "0": "565c5530303065303030315c5530303130666666660a70300a2e", + "1": "5808000000f3a08081f48fbfbf71002e", + "2": "80025808000000f3a08081f48fbfbf71002e", + "3": "80035808000000f3a08081f48fbfbf71002e", + "4": "8004950c000000000000008c08f3a08081f48fbfbf942e", + "5": "8005950c000000000000008c08f3a08081f48fbfbf942e" + }, + "view": "'\\U000e0001\\U0010ffff'" + }, + { + "name": "'\\b\\f'", + "source": "'\\b\\f'", + "literal": true, + "plain": true, + "repr": "'\\x08\\x0c'", + "str": "\b\f", + "truthy": true, + "json": "\"\\b\\f\"", + "pickle": { + "0": "56080c0a70300a2e", + "1": "5802000000080c71002e", + "2": "80025802000000080c71002e", + "3": "80035802000000080c71002e", + "4": "80049506000000000000008c02080c942e", + "5": "80059506000000000000008c02080c942e" + }, + "view": "'\\x08\\x0c'" + }, + { + "name": "b''", + "source": "b''", + "literal": true, + "plain": true, + "repr": "b''", + "str": "b''", + "truthy": false, + "json_error": "Object of type bytes is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a62797465730a70300a28745270310a2e", + "1": "635f5f6275696c74696e5f5f0a62797465730a7100295271012e", + "2": "8002635f5f6275696c74696e5f5f0a62797465730a7100295271012e", + "3": "8003430071002e", + "4": "80049504000000000000004300942e", + "5": "80059504000000000000004300942e" + }, + "view": "b''" + }, + { + "name": "b'abc'", + "source": "b'abc'", + "literal": true, + "plain": true, + "repr": "b'abc'", + "str": "b'abc'", + "truthy": true, + "json_error": "Object of type bytes is not JSON serializable", + "pickle": { + "0": "635f636f646563730a656e636f64650a70300a28566162630a70310a566c6174696e310a70320a7470330a5270340a2e", + "1": "635f636f646563730a656e636f64650a7100285803000000616263710158060000006c6174696e3171027471035271042e", + "2": "8002635f636f646563730a656e636f64650a71005803000000616263710158060000006c6174696e3171028671035271042e", + "3": "8003430361626371002e", + "4": "80049507000000000000004303616263942e", + "5": "80059507000000000000004303616263942e" + }, + "view": "b'abc'" + }, + { + "name": "b\"a'b\"", + "source": "b\"a'b\"", + "literal": true, + "plain": true, + "repr": "b\"a'b\"", + "str": "b\"a'b\"", + "truthy": true, + "json_error": "Object of type bytes is not JSON serializable", + "pickle": { + "0": "635f636f646563730a656e636f64650a70300a28566127620a70310a566c6174696e310a70320a7470330a5270340a2e", + "1": "635f636f646563730a656e636f64650a7100285803000000612762710158060000006c6174696e3171027471035271042e", + "2": "8002635f636f646563730a656e636f64650a71005803000000612762710158060000006c6174696e3171028671035271042e", + "3": "8003430361276271002e", + "4": "80049507000000000000004303612762942e", + "5": "80059507000000000000004303612762942e" + }, + "view": "b\"a'b\"" + }, + { + "name": "b'a\"b\\'c'", + "source": "b'a\"b\\'c'", + "literal": true, + "plain": true, + "repr": "b'a\"b\\'c'", + "str": "b'a\"b\\'c'", + "truthy": true, + "json_error": "Object of type bytes is not JSON serializable", + "pickle": { + "0": "635f636f646563730a656e636f64650a70300a285661226227630a70310a566c6174696e310a70320a7470330a5270340a2e", + "1": "635f636f646563730a656e636f64650a71002858050000006122622763710158060000006c6174696e3171027471035271042e", + "2": "8002635f636f646563730a656e636f64650a710058050000006122622763710158060000006c6174696e3171028671035271042e", + "3": "80034305612262276371002e", + "4": "800495090000000000000043056122622763942e", + "5": "800595090000000000000043056122622763942e" + }, + "view": "b'a\"b\\'c'" + }, + { + "name": "b'\\x00\\t\\n\\r\\x7f\\x80\\xff'", + "source": "b'\\x00\\t\\n\\r\\x7f\\x80\\xff'", + "literal": true, + "plain": true, + "repr": "b'\\x00\\t\\n\\r\\x7f\\x80\\xff'", + "str": "b'\\x00\\t\\n\\r\\x7f\\x80\\xff'", + "truthy": true, + "json_error": "Object of type bytes is not JSON serializable", + "pickle": { + "0": "635f636f646563730a656e636f64650a70300a28565c7530303030095c75303030615c75303030647f80ff0a70310a566c6174696e310a70320a7470330a5270340a2e", + "1": "635f636f646563730a656e636f64650a710028580900000000090a0d7fc280c3bf710158060000006c6174696e3171027471035271042e", + "2": "8002635f636f646563730a656e636f64650a7100580900000000090a0d7fc280c3bf710158060000006c6174696e3171028671035271042e", + "3": "8003430700090a0d7f80ff71002e", + "4": "8004950b00000000000000430700090a0d7f80ff942e", + "5": "8005950b00000000000000430700090a0d7f80ff942e" + }, + "view": "b'\\x00\\t\\n\\r\\x7f\\x80\\xff'" + }, + { + "name": "[]", + "source": "[]", + "literal": true, + "plain": true, + "repr": "[]", + "str": "[]", + "truthy": false, + "json": "[]", + "pickle": { + "0": "286c70300a2e", + "1": "5d71002e", + "2": "80025d71002e", + "3": "80035d71002e", + "4": "80045d942e", + "5": "80055d942e" + }, + "view": "[]" + }, + { + "name": "[1, 'a', None, True]", + "source": "[1, 'a', None, True]", + "literal": true, + "plain": true, + "repr": "[1, 'a', None, True]", + "str": "[1, 'a', None, True]", + "truthy": true, + "json": "[1, \"a\", null, true]", + "pickle": { + "0": "286c70300a49310a6156610a70310a614e614930310a612e", + "1": "5d7100284b0158010000006171014e4930310a652e", + "2": "80025d7100284b0158010000006171014e88652e", + "3": "80035d7100284b0158010000006171014e88652e", + "4": "8004950d000000000000005d94284b018c0161944e88652e", + "5": "8005950d000000000000005d94284b018c0161944e88652e" + }, + "view": "[1, 'a', None, True]" + }, + { + "name": "()", + "source": "()", + "literal": true, + "plain": true, + "repr": "()", + "str": "()", + "truthy": false, + "json": "[]", + "pickle": { + "0": "28742e", + "1": "292e", + "2": "8002292e", + "3": "8003292e", + "4": "8004292e", + "5": "8005292e" + }, + "view": "[]" + }, + { + "name": "(1,)", + "source": "(1,)", + "literal": true, + "plain": true, + "repr": "(1,)", + "str": "(1,)", + "truthy": true, + "json": "[1]", + "pickle": { + "0": "2849310a7470300a2e", + "1": "284b017471002e", + "2": "80024b018571002e", + "3": "80034b018571002e", + "4": "80049505000000000000004b0185942e", + "5": "80059505000000000000004b0185942e" + }, + "view": "[1]" + }, + { + "name": "(1, (2, 3))", + "source": "(1, (2, 3))", + "literal": true, + "plain": true, + "repr": "(1, (2, 3))", + "str": "(1, (2, 3))", + "truthy": true, + "json": "[1, [2, 3]]", + "pickle": { + "0": "2849310a2849320a49330a7470300a7470310a2e", + "1": "284b01284b024b037471007471012e", + "2": "80024b014b024b038671008671012e", + "3": "80034b014b024b038671008671012e", + "4": "8004950b000000000000004b014b024b03869486942e", + "5": "8005950b000000000000004b014b024b03869486942e" + }, + "view": "[1, [2, 3]]" + }, + { + "name": "{}", + "source": "{}", + "literal": true, + "plain": true, + "repr": "{}", + "str": "{}", + "truthy": false, + "json": "{}", + "pickle": { + "0": "286470300a2e", + "1": "7d71002e", + "2": "80027d71002e", + "3": "80037d71002e", + "4": "80047d942e", + "5": "80057d942e" + }, + "view": "{}" + }, + { + "name": "{'a': 1, 'b': [1.0, 2.5]}", + "source": "{'a': 1, 'b': [1.0, 2.5]}", + "literal": true, + "plain": true, + "repr": "{'a': 1, 'b': [1.0, 2.5]}", + "str": "{'a': 1, 'b': [1.0, 2.5]}", + "truthy": true, + "json": "{\"a\": 1, \"b\": [1.0, 2.5]}", + "pickle": { + "0": "286470300a56610a70310a49310a7356620a70320a286c70330a46312e300a6146322e350a61732e", + "1": "7d71002858010000006171014b0158010000006271025d710328473ff000000000000047400400000000000065752e", + "2": "80027d71002858010000006171014b0158010000006271025d710328473ff000000000000047400400000000000065752e", + "3": "80037d71002858010000006171014b0158010000006271025d710328473ff000000000000047400400000000000065752e", + "4": "80049525000000000000007d94288c0161944b018c0162945d9428473ff000000000000047400400000000000065752e", + "5": "80059525000000000000007d94288c0161944b018c0162945d9428473ff000000000000047400400000000000065752e" + }, + "view": "{'a': 1, 'b': [1.0, 2.5]}" + }, + { + "name": "{'z': 1, 'a': 2, 'm': 3}", + "source": "{'z': 1, 'a': 2, 'm': 3}", + "literal": true, + "plain": true, + "repr": "{'z': 1, 'a': 2, 'm': 3}", + "str": "{'z': 1, 'a': 2, 'm': 3}", + "truthy": true, + "json": "{\"z\": 1, \"a\": 2, \"m\": 3}", + "pickle": { + "0": "286470300a567a0a70310a49310a7356610a70320a49320a73566d0a70330a49330a732e", + "1": "7d71002858010000007a71014b0158010000006171024b0258010000006d71034b03752e", + "2": "80027d71002858010000007a71014b0158010000006171024b0258010000006d71034b03752e", + "3": "80037d71002858010000007a71014b0158010000006171024b0258010000006d71034b03752e", + "4": "80049517000000000000007d94288c017a944b018c0161944b028c016d944b03752e", + "5": "80059517000000000000007d94288c017a944b018c0161944b028c016d944b03752e" + }, + "view": "{'z': 1, 'a': 2, 'm': 3}" + }, + { + "name": "{1: 'int', 2.5: 'float', True: 'bool', None: 'none'}", + "source": "{1: 'int', 2.5: 'float', True: 'bool', None: 'none'}", + "literal": true, + "plain": true, + "repr": "{1: 'bool', 2.5: 'float', None: 'none'}", + "str": "{1: 'bool', 2.5: 'float', None: 'none'}", + "truthy": true, + "json": "{\"1\": \"bool\", \"2.5\": \"float\", \"null\": \"none\"}", + "pickle": { + "0": "286470300a49310a56626f6f6c0a70310a7346322e350a56666c6f61740a70320a734e566e6f6e650a70330a732e", + "1": "7d7100284b015804000000626f6f6c71014740040000000000005805000000666c6f617471024e58040000006e6f6e657103752e", + "2": "80027d7100284b015804000000626f6f6c71014740040000000000005805000000666c6f617471024e58040000006e6f6e657103752e", + "3": "80037d7100284b015804000000626f6f6c71014740040000000000005805000000666c6f617471024e58040000006e6f6e657103752e", + "4": "80049527000000000000007d94284b018c04626f6f6c944740040000000000008c05666c6f6174944e8c046e6f6e6594752e", + "5": "80059527000000000000007d94284b018c04626f6f6c944740040000000000008c05666c6f6174944e8c046e6f6e6594752e" + }, + "view": "{1: 'bool', 2.5: 'float', None: 'none'}" + }, + { + "name": "{(1, 2): 'tuple key'}", + "source": "{(1, 2): 'tuple key'}", + "literal": true, + "plain": true, + "repr": "{(1, 2): 'tuple key'}", + "str": "{(1, 2): 'tuple key'}", + "truthy": true, + "json_error": "keys must be str, int, float, bool or None, not tuple", + "pickle": { + "0": "286470300a2849310a49320a7470310a567475706c65206b65790a70320a732e", + "1": "7d7100284b014b0274710158090000007475706c65206b65797102732e", + "2": "80027d71004b014b0286710158090000007475706c65206b65797102732e", + "3": "80037d71004b014b0286710158090000007475706c65206b65797102732e", + "4": "80049516000000000000007d944b014b0286948c097475706c65206b657994732e", + "5": "80059516000000000000007d944b014b0286948c097475706c65206b657994732e" + }, + "view": "{[1, 2]: 'tuple key'}" + }, + { + "name": "{'nested': {'deeper': {'deepest': [{}]}}}", + "source": "{'nested': {'deeper': {'deepest': [{}]}}}", + "literal": true, + "plain": true, + "repr": "{'nested': {'deeper': {'deepest': [{}]}}}", + "str": "{'nested': {'deeper': {'deepest': [{}]}}}", + "truthy": true, + "json": "{\"nested\": {\"deeper\": {\"deepest\": [{}]}}}", + "pickle": { + "0": "286470300a566e65737465640a70310a286470320a566465657065720a70330a286470340a56646565706573740a70350a286c70360a286470370a617373732e", + "1": "7d710058060000006e657374656471017d7102580600000064656570657271037d710458070000006465657065737471055d71067d7107617373732e", + "2": "80027d710058060000006e657374656471017d7102580600000064656570657271037d710458070000006465657065737471055d71067d7107617373732e", + "3": "80037d710058060000006e657374656471017d7102580600000064656570657271037d710458070000006465657065737471055d71067d7107617373732e", + "4": "8004952b000000000000007d948c066e6573746564947d948c06646565706572947d948c0764656570657374945d947d94617373732e", + "5": "8005952b000000000000007d948c066e6573746564947d948c06646565706572947d948c0764656570657374945d947d94617373732e" + }, + "view": "{'nested': {'deeper': {'deepest': [{}]}}}" + }, + { + "name": "{1}", + "source": "{1}", + "literal": true, + "plain": true, + "repr": "{1}", + "str": "{1}", + "truthy": true, + "json_error": "Object of type set is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a7365740a70300a28286c70310a49310a617470320a5270330a2e", + "1": "635f5f6275696c74696e5f5f0a7365740a7100285d71014b01617471025271032e", + "2": "8002635f5f6275696c74696e5f5f0a7365740a71005d71014b01618571025271032e", + "3": "8003636275696c74696e730a7365740a71005d71014b01618571025271032e", + "4": "80049507000000000000008f94284b01902e", + "5": "80059507000000000000008f94284b01902e" + }, + "view": "[1]" + }, + { + "name": "set()", + "source": "set()", + "literal": true, + "plain": true, + "repr": "set()", + "str": "set()", + "truthy": false, + "json_error": "Object of type set is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a7365740a70300a28286c70310a7470320a5270330a2e", + "1": "635f5f6275696c74696e5f5f0a7365740a7100285d71017471025271032e", + "2": "8002635f5f6275696c74696e5f5f0a7365740a71005d71018571025271032e", + "3": "8003636275696c74696e730a7365740a71005d71018571025271032e", + "4": "80048f942e", + "5": "80058f942e" + }, + "view": "[]" + }, + { + "name": "frozenset({1})", + "source": "frozenset({1})", + "literal": false, + "plain": true, + "repr": "frozenset({1})", + "str": "frozenset({1})", + "truthy": true, + "json_error": "Object of type frozenset is not JSON serializable", + "pickle": { + "0": "635f5f6275696c74696e5f5f0a66726f7a656e7365740a70300a28286c70310a49310a617470320a5270330a2e", + "1": "635f5f6275696c74696e5f5f0a66726f7a656e7365740a7100285d71014b01617471025271032e", + "2": "8002635f5f6275696c74696e5f5f0a66726f7a656e7365740a71005d71014b01618571025271032e", + "3": "8003636275696c74696e730a66726f7a656e7365740a71005d71014b01618571025271032e", + "4": "8004950600000000000000284b0191942e", + "5": "8005950600000000000000284b0191942e" + }, + "view": "[1]" + }, + { + "name": "[[[[[[[[[[1]]]]]]]]]]", + "source": "[[[[[[[[[[1]]]]]]]]]]", + "literal": true, + "plain": true, + "repr": "[[[[[[[[[[1]]]]]]]]]]", + "str": "[[[[[[[[[[1]]]]]]]]]]", + "truthy": true, + "json": "[[[[[[[[[[1]]]]]]]]]]", + "pickle": { + "0": "286c70300a286c70310a286c70320a286c70330a286c70340a286c70350a286c70360a286c70370a286c70380a286c70390a49310a616161616161616161612e", + "1": "5d71005d71015d71025d71035d71045d71055d71065d71075d71085d71094b01616161616161616161612e", + "2": "80025d71005d71015d71025d71035d71045d71055d71065d71075d71085d71094b01616161616161616161612e", + "3": "80035d71005d71015d71025d71035d71045d71055d71065d71075d71085d71094b01616161616161616161612e", + "4": "80049521000000000000005d945d945d945d945d945d945d945d945d945d944b01616161616161616161612e", + "5": "80059521000000000000005d945d945d945d945d945d945d945d945d945d944b01616161616161616161612e" + }, + "view": "[[[[[[[[[[1]]]]]]]]]]" + }, + { + "name": "nested_150", + "source": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]", + "literal": true, + "plain": true, + "repr": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]", + "str": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]", + "truthy": true, + "json": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]", + "pickle": { + "0": "286c70300a286c70310a286c70320a286c70330a286c70340a286c70350a286c70360a286c70370a286c70380a286c70390a286c7031300a286c7031310a286c7031320a286c7031330a286c7031340a286c7031350a286c7031360a286c7031370a286c7031380a286c7031390a286c7032300a286c7032310a286c7032320a286c7032330a286c7032340a286c7032350a286c7032360a286c7032370a286c7032380a286c7032390a286c7033300a286c7033310a286c7033320a286c7033330a286c7033340a286c7033350a286c7033360a286c7033370a286c7033380a286c7033390a286c7034300a286c7034310a286c7034320a286c7034330a286c7034340a286c7034350a286c7034360a286c7034370a286c7034380a286c7034390a286c7035300a286c7035310a286c7035320a286c7035330a286c7035340a286c7035350a286c7035360a286c7035370a286c7035380a286c7035390a286c7036300a286c7036310a286c7036320a286c7036330a286c7036340a286c7036350a286c7036360a286c7036370a286c7036380a286c7036390a286c7037300a286c7037310a286c7037320a286c7037330a286c7037340a286c7037350a286c7037360a286c7037370a286c7037380a286c7037390a286c7038300a286c7038310a286c7038320a286c7038330a286c7038340a286c7038350a286c7038360a286c7038370a286c7038380a286c7038390a286c7039300a286c7039310a286c7039320a286c7039330a286c7039340a286c7039350a286c7039360a286c7039370a286c7039380a286c7039390a286c703130300a286c703130310a286c703130320a286c703130330a286c703130340a286c703130350a286c703130360a286c703130370a286c703130380a286c703130390a286c703131300a286c703131310a286c703131320a286c703131330a286c703131340a286c703131350a286c703131360a286c703131370a286c703131380a286c703131390a286c703132300a286c703132310a286c703132320a286c703132330a286c703132340a286c703132350a286c703132360a286c703132370a286c703132380a286c703132390a286c703133300a286c703133310a286c703133320a286c703133330a286c703133340a286c703133350a286c703133360a286c703133370a286c703133380a286c703133390a286c703134300a286c703134310a286c703134320a286c703134330a286c703134340a286c703134350a286c703134360a286c703134370a286c703134380a286c703134390a49310a6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e", + "1": "5d71005d71015d71025d71035d71045d71055d71065d71075d71085d71095d710a5d710b5d710c5d710d5d710e5d710f5d71105d71115d71125d71135d71145d71155d71165d71175d71185d71195d711a5d711b5d711c5d711d5d711e5d711f5d71205d71215d71225d71235d71245d71255d71265d71275d71285d71295d712a5d712b5d712c5d712d5d712e5d712f5d71305d71315d71325d71335d71345d71355d71365d71375d71385d71395d713a5d713b5d713c5d713d5d713e5d713f5d71405d71415d71425d71435d71445d71455d71465d71475d71485d71495d714a5d714b5d714c5d714d5d714e5d714f5d71505d71515d71525d71535d71545d71555d71565d71575d71585d71595d715a5d715b5d715c5d715d5d715e5d715f5d71605d71615d71625d71635d71645d71655d71665d71675d71685d71695d716a5d716b5d716c5d716d5d716e5d716f5d71705d71715d71725d71735d71745d71755d71765d71775d71785d71795d717a5d717b5d717c5d717d5d717e5d717f5d71805d71815d71825d71835d71845d71855d71865d71875d71885d71895d718a5d718b5d718c5d718d5d718e5d718f5d71905d71915d71925d71935d71945d71954b016161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e", + "2": "80025d71005d71015d71025d71035d71045d71055d71065d71075d71085d71095d710a5d710b5d710c5d710d5d710e5d710f5d71105d71115d71125d71135d71145d71155d71165d71175d71185d71195d711a5d711b5d711c5d711d5d711e5d711f5d71205d71215d71225d71235d71245d71255d71265d71275d71285d71295d712a5d712b5d712c5d712d5d712e5d712f5d71305d71315d71325d71335d71345d71355d71365d71375d71385d71395d713a5d713b5d713c5d713d5d713e5d713f5d71405d71415d71425d71435d71445d71455d71465d71475d71485d71495d714a5d714b5d714c5d714d5d714e5d714f5d71505d71515d71525d71535d71545d71555d71565d71575d71585d71595d715a5d715b5d715c5d715d5d715e5d715f5d71605d71615d71625d71635d71645d71655d71665d71675d71685d71695d716a5d716b5d716c5d716d5d716e5d716f5d71705d71715d71725d71735d71745d71755d71765d71775d71785d71795d717a5d717b5d717c5d717d5d717e5d717f5d71805d71815d71825d71835d71845d71855d71865d71875d71885d71895d718a5d718b5d718c5d718d5d718e5d718f5d71905d71915d71925d71935d71945d71954b016161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e", + "3": "80035d71005d71015d71025d71035d71045d71055d71065d71075d71085d71095d710a5d710b5d710c5d710d5d710e5d710f5d71105d71115d71125d71135d71145d71155d71165d71175d71185d71195d711a5d711b5d711c5d711d5d711e5d711f5d71205d71215d71225d71235d71245d71255d71265d71275d71285d71295d712a5d712b5d712c5d712d5d712e5d712f5d71305d71315d71325d71335d71345d71355d71365d71375d71385d71395d713a5d713b5d713c5d713d5d713e5d713f5d71405d71415d71425d71435d71445d71455d71465d71475d71485d71495d714a5d714b5d714c5d714d5d714e5d714f5d71505d71515d71525d71535d71545d71555d71565d71575d71585d71595d715a5d715b5d715c5d715d5d715e5d715f5d71605d71615d71625d71635d71645d71655d71665d71675d71685d71695d716a5d716b5d716c5d716d5d716e5d716f5d71705d71715d71725d71735d71745d71755d71765d71775d71785d71795d717a5d717b5d717c5d717d5d717e5d717f5d71805d71815d71825d71835d71845d71855d71865d71875d71885d71895d718a5d718b5d718c5d718d5d718e5d718f5d71905d71915d71925d71935d71945d71954b016161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e", + "4": "800495c5010000000000005d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d944b016161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e", + "5": "800595c5010000000000005d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d945d944b016161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161612e" + }, + "view": "[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]" + }, + { + "name": "{'timestamp': 1726000000.123, 'response': '{\"id\": \"chatcmpl-1\", \"object\": \"chat.completion\"}'}", + "source": "{'timestamp': 1726000000.123, 'response': '{\"id\": \"chatcmpl-1\", \"object\": \"chat.completion\"}'}", + "literal": true, + "plain": true, + "repr": "{'timestamp': 1726000000.123, 'response': '{\"id\": \"chatcmpl-1\", \"object\": \"chat.completion\"}'}", + "str": "{'timestamp': 1726000000.123, 'response': '{\"id\": \"chatcmpl-1\", \"object\": \"chat.completion\"}'}", + "truthy": true, + "json": "{\"timestamp\": 1726000000.123, \"response\": \"{\\\"id\\\": \\\"chatcmpl-1\\\", \\\"object\\\": \\\"chat.completion\\\"}\"}", + "pickle": { + "0": "286470300a5674696d657374616d700a70310a46313732363030303030302e3132330a7356726573706f6e73650a70320a567b226964223a202263686174636d706c2d31222c20226f626a656374223a2022636861742e636f6d706c6574696f6e227d0a70330a732e", + "1": "7d710028580900000074696d657374616d7071014741d9b82ae007df3b5808000000726573706f6e7365710258310000007b226964223a202263686174636d706c2d31222c20226f626a656374223a2022636861742e636f6d706c6574696f6e227d7103752e", + "2": "80027d710028580900000074696d657374616d7071014741d9b82ae007df3b5808000000726573706f6e7365710258310000007b226964223a202263686174636d706c2d31222c20226f626a656374223a2022636861742e636f6d706c6574696f6e227d7103752e", + "3": "80037d710028580900000074696d657374616d7071014741d9b82ae007df3b5808000000726573706f6e7365710258310000007b226964223a202263686174636d706c2d31222c20226f626a656374223a2022636861742e636f6d706c6574696f6e227d7103752e", + "4": "80049559000000000000007d94288c0974696d657374616d70944741d9b82ae007df3b8c08726573706f6e7365948c317b226964223a202263686174636d706c2d31222c20226f626a656374223a2022636861742e636f6d706c6574696f6e227d94752e", + "5": "80059559000000000000007d94288c0974696d657374616d70944741d9b82ae007df3b8c08726573706f6e7365948c317b226964223a202263686174636d706c2d31222c20226f626a656374223a2022636861742e636f6d706c6574696f6e227d94752e" + }, + "view": "{'timestamp': 1726000000.123, 'response': '{\"id\": \"chatcmpl-1\", \"object\": \"chat.completion\"}'}" + }, + { + "name": "{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}", + "source": "{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}", + "literal": true, + "plain": true, + "repr": "{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}", + "str": "{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}", + "truthy": true, + "json": "{\"model\": \"gpt-4o\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}], \"temperature\": 0.2, \"stream\": false}", + "pickle": { + "0": "286470300a566d6f64656c0a70310a566770742d346f0a70320a73566d657373616765730a70330a286c70340a286470350a56726f6c650a70360a56757365720a70370a7356636f6e74656e740a70380a5668690a70390a7361735674656d70657261747572650a7031300a46302e320a735673747265616d0a7031310a4930300a732e", + "1": "7d71002858050000006d6f64656c710158060000006770742d346f710258080000006d6573736167657371035d71047d7105285804000000726f6c65710658040000007573657271075807000000636f6e74656e7471085802000000686971097561580b00000074656d7065726174757265710a473fc999999999999a580600000073747265616d710b4930300a752e", + "2": "80027d71002858050000006d6f64656c710158060000006770742d346f710258080000006d6573736167657371035d71047d7105285804000000726f6c65710658040000007573657271075807000000636f6e74656e7471085802000000686971097561580b00000074656d7065726174757265710a473fc999999999999a580600000073747265616d710b89752e", + "3": "80037d71002858050000006d6f64656c710158060000006770742d346f710258080000006d6573736167657371035d71047d7105285804000000726f6c65710658040000007573657271075807000000636f6e74656e7471085802000000686971097561580b00000074656d7065726174757265710a473fc999999999999a580600000073747265616d710b89752e", + "4": "80049566000000000000007d94288c056d6f64656c948c066770742d346f948c086d65737361676573945d947d94288c04726f6c65948c0475736572948c07636f6e74656e74948c0268699475618c0b74656d706572617475726594473fc999999999999a8c0673747265616d9489752e", + "5": "80059566000000000000007d94288c056d6f64656c948c066770742d346f948c086d65737361676573945d947d94288c04726f6c65948c0475736572948c07636f6e74656e74948c0268699475618c0b74656d706572617475726594473fc999999999999a8c0673747265616d9489752e" + }, + "view": "{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}" + } + ], + "sources": [ + { + "name": "1", + "source": "1", + "repr": "1" + }, + { + "name": " 1", + "source": " 1", + "repr": "1" + }, + { + "name": "\t1", + "source": "\t1", + "repr": "1" + }, + { + "name": "\n1", + "source": "\n1", + "repr": "1" + }, + { + "name": " \n 1", + "source": " \n 1", + "error": "IndentationError" + }, + { + "name": "1\n", + "source": "1\n", + "repr": "1" + }, + { + "name": "1 # comment", + "source": "1 # comment", + "repr": "1" + }, + { + "name": "# c\n1", + "source": "# c\n1", + "repr": "1" + }, + { + "name": "1 \\\n", + "source": "1 \\\n", + "error": "SyntaxError" + }, + { + "name": "1,", + "source": "1,", + "repr": "(1,)" + }, + { + "name": "1, 2", + "source": "1, 2", + "repr": "(1, 2)" + }, + { + "name": "1,\n2", + "source": "1,\n2", + "error": "SyntaxError" + }, + { + "name": "(1,\n2)", + "source": "(1,\n2)", + "repr": "(1, 2)" + }, + { + "name": "[1,\n 2,\n]", + "source": "[1,\n 2,\n]", + "repr": "[1, 2]" + }, + { + "name": "()", + "source": "()", + "repr": "()" + }, + { + "name": "(1)", + "source": "(1)", + "repr": "1" + }, + { + "name": "((1,))", + "source": "((1,))", + "repr": "(1,)" + }, + { + "name": "(,)", + "source": "(,)", + "error": "SyntaxError" + }, + { + "name": "[,]", + "source": "[,]", + "error": "SyntaxError" + }, + { + "name": "{,}", + "source": "{,}", + "error": "SyntaxError" + }, + { + "name": "[1,]", + "source": "[1,]", + "repr": "[1]" + }, + { + "name": "{'a': 1,}", + "source": "{'a': 1,}", + "repr": "{'a': 1}" + }, + { + "name": "{1,}", + "source": "{1,}", + "repr": "{1}" + }, + { + "name": "{'a': 1 'b': 2}", + "source": "{'a': 1 'b': 2}", + "error": "SyntaxError" + }, + { + "name": "{1: 'a', True: 'b'}", + "source": "{1: 'a', True: 'b'}", + "repr": "{1: 'b'}" + }, + { + "name": "{1, True, 1.0}", + "source": "{1, True, 1.0}", + "repr": "{1}" + }, + { + "name": "{(1, 2): 'x', (1.0, 2): 'y'}", + "source": "{(1, 2): 'x', (1.0, 2): 'y'}", + "repr": "{(1, 2): 'y'}" + }, + { + "name": "{[1]: 2}", + "source": "{[1]: 2}", + "error": "TypeError" + }, + { + "name": "{{1}}", + "source": "{{1}}", + "error": "TypeError" + }, + { + "name": "{(1, [2])}", + "source": "{(1, [2])}", + "error": "TypeError" + }, + { + "name": "set()", + "source": "set()", + "repr": "set()" + }, + { + "name": "set( )", + "source": "set( )", + "repr": "set()" + }, + { + "name": "set([1])", + "source": "set([1])", + "error": "ValueError" + }, + { + "name": "frozenset()", + "source": "frozenset()", + "error": "ValueError" + }, + { + "name": "True", + "source": "True", + "repr": "True" + }, + { + "name": "False", + "source": "False", + "repr": "False" + }, + { + "name": "None", + "source": "None", + "repr": "None" + }, + { + "name": "Truex", + "source": "Truex", + "error": "ValueError" + }, + { + "name": "true", + "source": "true", + "error": "ValueError" + }, + { + "name": "...", + "source": "...", + "repr": "Ellipsis" + }, + { + "name": "0", + "source": "0", + "repr": "0" + }, + { + "name": "00", + "source": "00", + "repr": "0" + }, + { + "name": "0_0", + "source": "0_0", + "repr": "0" + }, + { + "name": "01", + "source": "01", + "error": "SyntaxError" + }, + { + "name": "007", + "source": "007", + "error": "SyntaxError" + }, + { + "name": "1_000", + "source": "1_000", + "repr": "1000" + }, + { + "name": "1_", + "source": "1_", + "error": "SyntaxError" + }, + { + "name": "1__0", + "source": "1__0", + "error": "SyntaxError" + }, + { + "name": "_1", + "source": "_1", + "error": "ValueError" + }, + { + "name": "0x1F", + "source": "0x1F", + "repr": "31" + }, + { + "name": "0X_1f", + "source": "0X_1f", + "repr": "31" + }, + { + "name": "0o17", + "source": "0o17", + "repr": "15" + }, + { + "name": "0b101", + "source": "0b101", + "repr": "5" + }, + { + "name": "0b102", + "source": "0b102", + "error": "SyntaxError" + }, + { + "name": "0x", + "source": "0x", + "error": "SyntaxError" + }, + { + "name": "1e3", + "source": "1e3", + "repr": "1000.0" + }, + { + "name": "1E-3", + "source": "1E-3", + "repr": "0.001" + }, + { + "name": "1e", + "source": "1e", + "error": "SyntaxError" + }, + { + "name": "1.e5", + "source": "1.e5", + "repr": "100000.0" + }, + { + "name": ".5", + "source": ".5", + "repr": "0.5" + }, + { + "name": "5.", + "source": "5.", + "repr": "5.0" + }, + { + "name": "1..", + "source": "1..", + "error": "SyntaxError" + }, + { + "name": "1.5.2", + "source": "1.5.2", + "error": "SyntaxError" + }, + { + "name": "1_0.0_1e1_0", + "source": "1_0.0_1e1_0", + "repr": "100100000000.0" + }, + { + "name": "1e999", + "source": "1e999", + "repr": "inf" + }, + { + "name": "-1e999", + "source": "-1e999", + "repr": "-inf" + }, + { + "name": "1j", + "source": "1j", + "repr": "1j" + }, + { + "name": "1.5J", + "source": "1.5J", + "repr": "1.5j" + }, + { + "name": "010j", + "source": "010j", + "repr": "10j" + }, + { + "name": "010.5", + "source": "010.5", + "repr": "10.5" + }, + { + "name": "1a", + "source": "1a", + "error": "SyntaxError" + }, + { + "name": "0x1g", + "source": "0x1g", + "error": "SyntaxError" + }, + { + "name": "-1", + "source": "-1", + "repr": "-1" + }, + { + "name": "+1", + "source": "+1", + "repr": "1" + }, + { + "name": "- 1", + "source": "- 1", + "repr": "-1" + }, + { + "name": "--1", + "source": "--1", + "error": "ValueError" + }, + { + "name": "-+1", + "source": "-+1", + "error": "ValueError" + }, + { + "name": "-(1)", + "source": "-(1)", + "repr": "-1" + }, + { + "name": "-(-1)", + "source": "-(-1)", + "error": "ValueError" + }, + { + "name": "-(1+2j)", + "source": "-(1+2j)", + "error": "ValueError" + }, + { + "name": "-True", + "source": "-True", + "error": "ValueError" + }, + { + "name": "-'a'", + "source": "-'a'", + "error": "ValueError" + }, + { + "name": "-[1]", + "source": "-[1]", + "error": "ValueError" + }, + { + "name": "1+2j", + "source": "1+2j", + "repr": "(1+2j)" + }, + { + "name": "1-2j", + "source": "1-2j", + "repr": "(1-2j)" + }, + { + "name": "1 + 2j", + "source": "1 + 2j", + "repr": "(1+2j)" + }, + { + "name": "(1)+(2j)", + "source": "(1)+(2j)", + "repr": "(1+2j)" + }, + { + "name": "1+2", + "source": "1+2", + "error": "ValueError" + }, + { + "name": "1+2+3j", + "source": "1+2+3j", + "error": "ValueError" + }, + { + "name": "1+-2j", + "source": "1+-2j", + "error": "ValueError" + }, + { + "name": "2j+1", + "source": "2j+1", + "error": "ValueError" + }, + { + "name": "True+1j", + "source": "True+1j", + "error": "ValueError" + }, + { + "name": "1-0j", + "source": "1-0j", + "repr": "(1-0j)" + }, + { + "name": "0.0-0j", + "source": "0.0-0j", + "repr": "-0j" + }, + { + "name": "-0.0+1j", + "source": "-0.0+1j", + "repr": "1j" + }, + { + "name": "-0.0", + "source": "-0.0", + "repr": "-0.0" + }, + { + "name": "-0", + "source": "-0", + "repr": "0" + }, + { + "name": "(-0.0)", + "source": "(-0.0)", + "repr": "-0.0" + }, + { + "name": "[-0.0, (-0.0)]", + "source": "[-0.0, (-0.0)]", + "repr": "[-0.0, -0.0]" + }, + { + "name": "2**3", + "source": "2**3", + "error": "ValueError" + }, + { + "name": "1*2", + "source": "1*2", + "error": "ValueError" + }, + { + "name": "1 if 1 else 2", + "source": "1 if 1 else 2", + "error": "ValueError" + }, + { + "name": "(1,)(2)", + "source": "(1,)(2)", + "error": "ValueError" + }, + { + "name": "''", + "source": "''", + "repr": "''" + }, + { + "name": "\"\"", + "source": "\"\"", + "repr": "''" + }, + { + "name": "'a' 'b'", + "source": "'a' 'b'", + "repr": "'ab'" + }, + { + "name": "'a' \"b\" '''c'''", + "source": "'a' \"b\" '''c'''", + "repr": "'abc'" + }, + { + "name": "'a' b'b'", + "source": "'a' b'b'", + "error": "SyntaxError" + }, + { + "name": "b'a' b'b'", + "source": "b'a' b'b'", + "repr": "b'ab'" + }, + { + "name": "u'x'", + "source": "u'x'", + "repr": "'x'" + }, + { + "name": "U'x'", + "source": "U'x'", + "repr": "'x'" + }, + { + "name": "r'x'", + "source": "r'x'", + "repr": "'x'" + }, + { + "name": "R'x'", + "source": "R'x'", + "repr": "'x'" + }, + { + "name": "b'x'", + "source": "b'x'", + "repr": "b'x'" + }, + { + "name": "B'x'", + "source": "B'x'", + "repr": "b'x'" + }, + { + "name": "br'x'", + "source": "br'x'", + "repr": "b'x'" + }, + { + "name": "Rb'x'", + "source": "Rb'x'", + "repr": "b'x'" + }, + { + "name": "rB'x'", + "source": "rB'x'", + "repr": "b'x'" + }, + { + "name": "ur'x'", + "source": "ur'x'", + "error": "SyntaxError" + }, + { + "name": "bu'x'", + "source": "bu'x'", + "error": "SyntaxError" + }, + { + "name": "f'x'", + "source": "f'x'", + "error": "ValueError" + }, + { + "name": "rf'x'", + "source": "rf'x'", + "error": "ValueError" + }, + { + "name": "'''a\nb'''", + "source": "'''a\nb'''", + "repr": "'a\\nb'" + }, + { + "name": "\"\"\"a\\\"\"\"\"", + "source": "\"\"\"a\\\"\"\"\"", + "repr": "'a\"'" + }, + { + "name": "'a\nb'", + "source": "'a\nb'", + "error": "SyntaxError" + }, + { + "name": "'a\\\nb'", + "source": "'a\\\nb'", + "repr": "'ab'" + }, + { + "name": "r'a\\\nb'", + "source": "r'a\\\nb'", + "repr": "'a\\\\\\nb'" + }, + { + "name": "'unterminated", + "source": "'unterminated", + "error": "SyntaxError" + }, + { + "name": "'\\a\\b\\f\\n\\r\\t\\v'", + "source": "'\\a\\b\\f\\n\\r\\t\\v'", + "repr": "'\\x07\\x08\\x0c\\n\\r\\t\\x0b'" + }, + { + "name": "'\\0\\12\\101\\1011'", + "source": "'\\0\\12\\101\\1011'", + "repr": "'\\x00\\nAA1'" + }, + { + "name": "'\\777'", + "source": "'\\777'", + "repr": "'\u01ff'" + }, + { + "name": "b'\\777'", + "source": "b'\\777'", + "repr": "b'\\xff'" + }, + { + "name": "b'\\400'", + "source": "b'\\400'", + "repr": "b'\\x00'" + }, + { + "name": "'\\x41'", + "source": "'\\x41'", + "repr": "'A'" + }, + { + "name": "'\\x4'", + "source": "'\\x4'", + "error": "SyntaxError" + }, + { + "name": "'\\u00e9'", + "source": "'\\u00e9'", + "repr": "'\u00e9'" + }, + { + "name": "'\\u00e'", + "source": "'\\u00e'", + "error": "SyntaxError" + }, + { + "name": "'\\U0001F600'", + "source": "'\\U0001F600'", + "repr": "'\ud83d\ude00'" + }, + { + "name": "'\\U00110000'", + "source": "'\\U00110000'", + "error": "SyntaxError" + }, + { + "name": "'\\ud800'", + "source": "'\\ud800'", + "repr": "'\\ud800'" + }, + { + "name": "'\\N{BULLET}'", + "source": "'\\N{BULLET}'", + "repr": "'\u2022'" + }, + { + "name": "'\\q'", + "source": "'\\q'", + "repr": "'\\\\q'" + }, + { + "name": "'\\\\'", + "source": "'\\\\'", + "repr": "'\\\\'" + }, + { + "name": "'\\''", + "source": "'\\''", + "repr": "\"'\"" + }, + { + "name": "\"\\\"\"", + "source": "\"\\\"\"", + "repr": "'\"'" + }, + { + "name": "b'\\u0041'", + "source": "b'\\u0041'", + "repr": "b'\\\\u0041'" + }, + { + "name": "b'\\x41\\xff'", + "source": "b'\\x41\\xff'", + "repr": "b'A\\xff'" + }, + { + "name": "b'caf\u00e9'", + "source": "b'caf\u00e9'", + "error": "SyntaxError" + }, + { + "name": "'caf\u00e9'", + "source": "'caf\u00e9'", + "repr": "'caf\u00e9'" + }, + { + "name": "r'\\d'", + "source": "r'\\d'", + "repr": "'\\\\d'" + }, + { + "name": "r'\\''", + "source": "r'\\''", + "repr": "\"\\\\'\"" + }, + { + "name": "rb'\\d'", + "source": "rb'\\d'", + "repr": "b'\\\\d'" + }, + { + "name": "r'\\'", + "source": "r'\\'", + "error": "SyntaxError" + }, + { + "name": "'\u65e5\ud83d\ude00'", + "source": "'\u65e5\ud83d\ude00'", + "repr": "'\u65e5\ud83d\ude00'" + } + ] +} diff --git a/litellm-rust/crates/python-compat/scripts/generate_fixtures.py b/litellm-rust/crates/python-compat/scripts/generate_fixtures.py new file mode 100644 index 00000000000..26456a638f6 --- /dev/null +++ b/litellm-rust/crates/python-compat/scripts/generate_fixtures.py @@ -0,0 +1,352 @@ +"""Regenerate generated/values.json: what CPython produces for each value in CORPUS. + + python scripts/generate_fixtures.py > generated/values.json + +Each row records `repr`, `str`, `json.dumps` (or its error), `bool`, and `pickle.dumps` at +every protocol. `literal` says whether `ast.literal_eval(repr(value))` gives the value back, +which is how Python reads `str(dict)` text back from a cache; the Rust tests reach the other +rows only through pickle. `view` is `repr` of the value as `pickle::loads` decodes it, with +tuples, sets, and frozensets rendered as lists. `sources` records `ast.literal_eval` on raw +source texts: its result, or the exception it raises. +""" + +import ast +import json +import pickle +import sys +import warnings + +# Entries are source texts, or `(name, source)` when the source is too long to read in a +# test report. `name` is what the Rust `KNOWN` table keys on. +CORPUS = [ + # Scalars + "None", + "True", + "False", + "0", + "-7", + "2**63 - 1", + "-(2**63)", + "2**64", + "-(2**70)", + # Floats around CPython's repr thresholds + "0.0", + "-0.0", + "0.2", + "1.0", + "-1.5", + "0.1 + 0.2", + "123456789.123", + "1e15", + "1e16", + "1.5e16", + "9999999999999998.0", + "0.0001", + "1e-05", + "1.25e-07", + "5e-324", + "1.7976931348623157e308", + "1e22", + "float('inf')", + "float('-inf')", + "float('nan')", + # Complex + "1j", + "-1j", + "complex(0, -1)", + "1+2j", + "-1.5-0.5j", + "complex(0.0, 1e16)", + # Strings: quote selection, escapes, printable and non-printable non-ASCII + "''", + "'plain'", + '"it\'s"', + "'say \"hi\"'", + "'both \\' and \"'", + "'back\\\\slash'", + "'\\t\\n\\r'", + "'\\x00\\x1f\\x7f'", + "'\\x85\\xa0\\xad'", + "'caf\\xe9'", + "'\\u65e5\\u672c'", + "'\\u200b\\u2028\\u3000'", + "'\\U0001f600'", + "'\\U000e0001\\U0010ffff'", + "'\\b\\f'", + # Bytes + "b''", + "b'abc'", + 'b"a\'b"', + "b'a\"b\\'c'", + "b'\\x00\\t\\n\\r\\x7f\\x80\\xff'", + # Containers + "[]", + "[1, 'a', None, True]", + "()", + "(1,)", + "(1, (2, 3))", + "{}", + "{'a': 1, 'b': [1.0, 2.5]}", + "{'z': 1, 'a': 2, 'm': 3}", + "{1: 'int', 2.5: 'float', True: 'bool', None: 'none'}", + "{(1, 2): 'tuple key'}", + "{'nested': {'deeper': {'deepest': [{}]}}}", + "{1}", + "set()", + "frozenset({1})", + "[[[[[[[[[[1]]]]]]]]]]", + # Deeper than the Rust decoders allow: CPython's parser accepts ~200 nested brackets + # and its unpickler has no limit, so this row records a deliberate divergence. + ("nested_150", "[" * 150 + "1" + "]" * 150), + # The shape LiteLLM caches + '{\'timestamp\': 1726000000.123, \'response\': \'{"id": "chatcmpl-1", "object": "chat.completion"}\'}', + "{'model': 'gpt-4o', 'messages': [{'role': 'user', 'content': 'hi'}], 'temperature': 0.2, 'stream': False}", +] + +# Source texts for `literal_eval` itself: tokenizer and evaluator edge cases, recorded with +# CPython's result or the exception it raises. Raw strings keep backslashes literal. +SOURCES = [ + # Layout: leading/trailing whitespace, comments, newlines, continuations + "1", + " 1", + "\t1", + "\n1", + " \n 1", + "1\n", + "1 # comment", + "# c\n1", + "1 \\\n", + "1,", + "1, 2", + "1,\n2", + "(1,\n2)", + "[1,\n 2,\n]", + # Containers and grouping + "()", + "(1)", + "((1,))", + "(,)", + "[,]", + "{,}", + "[1,]", + "{'a': 1,}", + "{1,}", + "{'a': 1 'b': 2}", + "{1: 'a', True: 'b'}", + "{1, True, 1.0}", + "{(1, 2): 'x', (1.0, 2): 'y'}", + "{[1]: 2}", + "{{1}}", + "{(1, [2])}", + "set()", + "set( )", + "set([1])", + "frozenset()", + # Names + "True", + "False", + "None", + "Truex", + "true", + "...", + # Integers and floats + "0", + "00", + "0_0", + "01", + "007", + "1_000", + "1_", + "1__0", + "_1", + "0x1F", + "0X_1f", + "0o17", + "0b101", + "0b102", + "0x", + "1e3", + "1E-3", + "1e", + "1.e5", + ".5", + "5.", + "1..", + "1.5.2", + "1_0.0_1e1_0", + "1e999", + "-1e999", + "1j", + "1.5J", + "010j", + "010.5", + "1a", + "0x1g", + # Signs and complex sums + "-1", + "+1", + "- 1", + "--1", + "-+1", + "-(1)", + "-(-1)", + "-(1+2j)", + "-True", + "-'a'", + "-[1]", + "1+2j", + "1-2j", + "1 + 2j", + "(1)+(2j)", + "1+2", + "1+2+3j", + "1+-2j", + "2j+1", + "True+1j", + "1-0j", + "0.0-0j", + "-0.0+1j", + "-0.0", + "-0", + "(-0.0)", + "[-0.0, (-0.0)]", + "2**3", + "1*2", + "1 if 1 else 2", + "(1,)(2)", + # String prefixes, quoting, and concatenation + "''", + '""', + "'a' 'b'", + "'a' \"b\" '''c'''", + "'a' b'b'", + "b'a' b'b'", + "u'x'", + "U'x'", + "r'x'", + "R'x'", + "b'x'", + "B'x'", + "br'x'", + "Rb'x'", + "rB'x'", + "ur'x'", + "bu'x'", + "f'x'", + "rf'x'", + "'''a\nb'''", + '"""a\\""""', + "'a\nb'", + "'a\\\nb'", + "r'a\\\nb'", + "'unterminated", + # Escapes + r"'\a\b\f\n\r\t\v'", + r"'\0\12\101\1011'", + r"'\777'", + r"b'\777'", + r"b'\400'", + r"'\x41'", + r"'\x4'", + r"'\u00e9'", + r"'\u00e'", + r"'\U0001F600'", + r"'\U00110000'", + r"'\ud800'", + r"'\N{BULLET}'", + r"'\q'", + r"'\\'", + r"'\''", + r'"\""', + r"b'\u0041'", + r"b'\x41\xff'", + "b'café'", + "'café'", + r"r'\d'", + r"r'\''", + r"rb'\d'", + r"r'\'", + "'日\U0001f600'", +] + + +def named(entry): + """Split a corpus entry into its report name and its source text.""" + if isinstance(entry, tuple): + return entry + return entry, entry + + +def evaluate(entry): + name, source = named(entry) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + try: + return {"name": name, "source": source, "repr": repr(ast.literal_eval(source))} + except Exception as error: # noqa: BLE001 - recorded, not raised + return {"name": name, "source": source, "error": type(error).__name__} + + +def view(value): + if isinstance(value, (list, tuple, set, frozenset)): + return "[" + ", ".join(view(item) for item in value) + "]" + if isinstance(value, dict): + return "{" + ", ".join(f"{view(key)}: {view(item)}" for key, item in value.items()) + "}" + return repr(value) + + +def plain(value): + """Whether pickle can encode the value without a class reference such as `complex`.""" + if isinstance(value, complex): + return False + if isinstance(value, (list, tuple, set, frozenset)): + return all(plain(item) for item in value) + if isinstance(value, dict): + return all(plain(key) and plain(item) for key, item in value.items()) + return True + + +def is_literal(value): + try: + parsed = ast.literal_eval(repr(value)) + except (ValueError, SyntaxError): + return False + return repr(parsed) == repr(value) + + +def row(entry): + name, source = named(entry) + value = eval(source) + entry = { + "name": name, + "source": source, + "literal": is_literal(value), + "plain": plain(value), + "repr": repr(value), + "str": str(value), + "truthy": bool(value), + } + try: + entry["json"] = json.dumps(value) + except (TypeError, ValueError) as error: + entry["json_error"] = str(error) + try: + entry["pickle"] = {str(protocol): pickle.dumps(value, protocol=protocol).hex() for protocol in range(6)} + entry["view"] = view(value) + except Exception as error: # noqa: BLE001 - recorded, not raised + entry["pickle_error"] = f"{type(error).__name__}: {error}" + return entry + + +if __name__ == "__main__": + json.dump( + { + "python": sys.version.split()[0], + "rows": [row(entry) for entry in CORPUS], + "sources": [evaluate(entry) for entry in SOURCES], + }, + sys.stdout, + indent=2, + ensure_ascii=True, + ) + sys.stdout.write("\n") diff --git a/litellm-rust/crates/python-compat/scripts/generate_nonprintable.py b/litellm-rust/crates/python-compat/scripts/generate_nonprintable.py new file mode 100644 index 00000000000..70d1fbd2dd1 --- /dev/null +++ b/litellm-rust/crates/python-compat/scripts/generate_nonprintable.py @@ -0,0 +1,35 @@ +"""Regenerate generated/nonprintable.rs from this interpreter's `str.isprintable`. + +`repr(str)` escapes exactly the characters for which `str.isprintable()` is false, so the +table must come from the Python version the gateway interoperates with. + + python scripts/generate_nonprintable.py > generated/nonprintable.rs +""" + +import sys +import unicodedata + +ranges = [] +start = None +for code in range(0x110000): + printable = chr(code).isprintable() + if not printable and start is None: + start = code + elif printable and start is not None: + ranges.append((start, code - 1)) + start = None +if start is not None: + ranges.append((start, 0x10FFFF)) + +lines = [ + f"// Generated by scripts/generate_nonprintable.py from Python {sys.version.split()[0]}", + f"// (Unicode {unicodedata.unidata_version}). Do not edit by hand.", + "", + f'pub(crate) const UNICODE_VERSION: &str = "{unicodedata.unidata_version}";', + "", + "/// Inclusive code point ranges for which Python's `str.isprintable()` is false.", + f"pub(crate) const NONPRINTABLE: [(u32, u32); {len(ranges)}] = [", + *(f" (0x{low:04X}, 0x{high:04X})," for low, high in ranges), + "];", +] +sys.stdout.write("\n".join(lines) + "\n") diff --git a/litellm-rust/crates/python-compat/scripts/verify_rust_pickles.py b/litellm-rust/crates/python-compat/scripts/verify_rust_pickles.py new file mode 100644 index 00000000000..793acad61f9 --- /dev/null +++ b/litellm-rust/crates/python-compat/scripts/verify_rust_pickles.py @@ -0,0 +1,43 @@ +"""Check that CPython unpickles what `pickle::dumps` writes, to the value it was given. + + PYTHON_COMPAT_RUST_PICKLES=rust.tsv cargo test -p litellm-python-compat --test fixtures + python scripts/verify_rust_pickles.py rust.tsv + +The rows are plain data by construction, so this refuses to resolve any class rather than +handing file-controlled bytes to an unrestricted `pickle.loads`. +""" + +import ast +import io +import pickle +import sys + + +class PlainDataUnpickler(pickle.Unpickler): + """An unpickler with `GLOBAL`/`REDUCE` disabled, mirroring `pickle::loads` in Rust.""" + + def find_class(self, module, name): + raise pickle.UnpicklingError(f"refusing to resolve {module}.{name}") + + +def loads(data): + return PlainDataUnpickler(io.BytesIO(data)).load() + + +def main(path): + failures = 0 + rows = 0 + with open(path, encoding="utf-8") as lines: + for line in lines: + data, expected = line.rstrip("\n").split("\t", 1) + rows += 1 + actual = repr(loads(bytes.fromhex(data))) + if actual != repr(ast.literal_eval(expected)): + failures += 1 + sys.stdout.write(f"mismatch: expected {expected}, got {actual}\n") + sys.stdout.write(f"{rows} Rust pickles checked, {failures} mismatches\n") + return 1 if failures or not rows else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/litellm-rust/crates/python-compat/src/error.rs b/litellm-rust/crates/python-compat/src/error.rs new file mode 100644 index 00000000000..43990eb7602 --- /dev/null +++ b/litellm-rust/crates/python-compat/src/error.rs @@ -0,0 +1,25 @@ +use crate::MAX_DEPTH; + +/// A failure to read or write a Python format. Messages quote CPython's own wording where +/// the Python side raises (`TypeError`, `ValueError`), so callers can log them as is. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("malformed Python literal at byte {0}")] + InvalidLiteral(usize), + #[error("unhashable type: '{0}'")] + Unhashable(&'static str), + #[error("value nests deeper than {MAX_DEPTH} levels")] + TooDeep, + #[error("invalid pickle: {0}")] + InvalidPickle(String), + #[error("Object of type {0} is not JSON serializable")] + NotJsonSerializable(&'static str), + #[error("keys must be str, int, float, bool or None, not {0}")] + InvalidJsonKey(&'static str), + #[error("Out of range float values are not JSON compliant")] + NonFiniteFloat, + #[error("integer does not fit in the JSON number range")] + IntegerOutOfRange, + #[error("Object of type {0} cannot be pickled as plain data")] + NotPicklable(&'static str), +} diff --git a/litellm-rust/crates/python-compat/src/json.rs b/litellm-rust/crates/python-compat/src/json.rs new file mode 100644 index 00000000000..d3436519ac4 --- /dev/null +++ b/litellm-rust/crates/python-compat/src/json.rs @@ -0,0 +1,168 @@ +//! `json.dumps` with CPython's default options, and the JSON value `json.loads` returns. +//! +//! Defaults are `ensure_ascii=True`, `allow_nan=True`, separators `(", ", ": ")`, and no key +//! sorting. Python's `json.loads` is mapped by [`from_json`]; a `serde_json` number that does +//! not fit `i64` or `u64` arrives as a float, where Python would keep an `int`. + +use std::fmt::Write; + +use serde_json::{Map, Number}; + +use crate::{Error, Value, repr::float_repr}; + +/// `json.dumps(value)`. +pub fn dumps(value: &Value) -> Result { + let mut out = String::new(); + write_value(&mut out, value)?; + Ok(out) +} + +/// The Python value `json.loads` returns for a JSON document. +pub fn from_json(value: serde_json::Value) -> Value { + match value { + serde_json::Value::Null => Value::None, + serde_json::Value::Bool(value) => Value::Bool(value), + serde_json::Value::Number(number) => { + if let Some(value) = number.as_i64() { + Value::Int(value.into()) + } else if let Some(value) = number.as_u64() { + Value::Int(value.into()) + } else { + Value::Float(number.as_f64().unwrap_or(f64::NAN)) + } + } + serde_json::Value::String(text) => Value::Str(text), + serde_json::Value::Array(values) => { + Value::List(values.into_iter().map(from_json).collect()) + } + serde_json::Value::Object(entries) => Value::Dict( + entries + .into_iter() + .map(|(key, value)| (Value::Str(key), from_json(value))) + .collect(), + ), + } +} + +/// `json.loads(json.dumps(value))` as a `serde_json` value: tuples become arrays and dict +/// keys are coerced to strings as `json.dumps` does. Non-finite floats, which Python writes +/// as `NaN` and `Infinity`, have no `serde_json` form and fail with [`Error::NonFiniteFloat`]. +pub fn to_json(value: &Value) -> Result { + Ok(match value { + Value::None => serde_json::Value::Null, + Value::Bool(value) => serde_json::Value::Bool(*value), + Value::Int(value) => { + let text = value.to_string(); + serde_json::Value::Number( + text.parse::() + .map(Number::from) + .or_else(|_| text.parse::().map(Number::from)) + .map_err(|_| Error::IntegerOutOfRange)?, + ) + } + Value::Float(value) => { + serde_json::Value::Number(Number::from_f64(*value).ok_or(Error::NonFiniteFloat)?) + } + Value::Str(text) => serde_json::Value::String(text.clone()), + Value::List(values) | Value::Tuple(values) => { + serde_json::Value::Array(values.iter().map(to_json).collect::, _>>()?) + } + Value::Dict(entries) => serde_json::Value::Object( + entries + .iter() + .map(|(key, value)| Ok((json_key(key)?, to_json(value)?))) + .collect::, Error>>()?, + ), + value @ (Value::Bytes(_) | Value::Set(_) | Value::Complex { .. }) => { + return Err(Error::NotJsonSerializable(value.type_name())); + } + }) +} + +fn write_value(out: &mut String, value: &Value) -> Result<(), Error> { + match value { + Value::None => out.push_str("null"), + Value::Bool(true) => out.push_str("true"), + Value::Bool(false) => out.push_str("false"), + Value::Int(value) => { + let _ = write!(out, "{value}"); + } + Value::Float(value) => out.push_str(&float_text(*value)), + Value::Str(text) => write_string(out, text), + Value::List(values) | Value::Tuple(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + write_value(out, value)?; + } + out.push(']'); + } + Value::Dict(entries) => { + out.push('{'); + for (index, (key, value)) in entries.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + write_string(out, &json_key(key)?); + out.push_str(": "); + write_value(out, value)?; + } + out.push('}'); + } + value @ (Value::Bytes(_) | Value::Set(_) | Value::Complex { .. }) => { + return Err(Error::NotJsonSerializable(value.type_name())); + } + } + Ok(()) +} + +/// `json.encoder.JSONEncoder.iterencode`'s `floatstr` with `allow_nan=True`. +fn float_text(value: f64) -> String { + if value.is_nan() { + "NaN".to_owned() + } else if value.is_infinite() { + if value > 0.0 { "Infinity" } else { "-Infinity" }.to_owned() + } else { + float_repr(value) + } +} + +/// Dict key coercion in `json.dumps`: scalars become their JSON text, other keys fail. +fn json_key(key: &Value) -> Result { + Ok(match key { + Value::Str(text) => text.clone(), + Value::Int(value) => value.to_string(), + Value::Float(value) => float_text(*value), + Value::Bool(true) => "true".to_owned(), + Value::Bool(false) => "false".to_owned(), + Value::None => "null".to_owned(), + key => return Err(Error::InvalidJsonKey(key.type_name())), + }) +} + +/// `py_encode_basestring_ascii`: escape `"`, `\`, control characters, and everything outside +/// printable ASCII as `\uXXXX`, with surrogate pairs above the BMP. +fn write_string(out: &mut String, text: &str) { + out.push('"'); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + ' '..='~' => out.push(ch), + ch => { + let mut units = [0u16; 2]; + for unit in ch.encode_utf16(&mut units) { + let _ = write!(out, "\\u{unit:04x}"); + } + } + } + } + out.push('"'); +} diff --git a/litellm-rust/crates/python-compat/src/lib.rs b/litellm-rust/crates/python-compat/src/lib.rs new file mode 100644 index 00000000000..b23aff409f2 --- /dev/null +++ b/litellm-rust/crates/python-compat/src/lib.rs @@ -0,0 +1,39 @@ +//! Python data formats reproduced in Rust, for state that Python LiteLLM writes and reads. +//! +//! Each module mirrors one Python operation over plain data values, and its tests replay +//! fixtures generated by that operation in CPython (`scripts/generate_fixtures.py`): +//! +//! | Module | Python operation | +//! |---|---| +//! | [`literal`] | `ast.literal_eval(text)` | +//! | [`repr`] | `repr(value)` and `str(value)` | +//! | [`json`] | `json.dumps(value)`, and `json.loads(json.dumps(value))` as a JSON value | +//! | [`pickle`] | `pickle.loads(data)` and `pickle.dumps(value)` for plain data | +//! | [`truthy`] | `bool(value)` | +//! +//! [`Value`] is the closed data model these formats share. Live Python objects +//! (descriptors, `__bool__`, `__str__`, callbacks) are out of scope: those belong to the +//! PyO3 boundary in `litellm-python-bridge`, which runs the real protocol. +//! +//! Known limits, each pinned by a test: +//! - `set` iteration order follows Python's hash order, which this crate does not model +//! (string hashes are randomized per process). Sets keep their literal order. +//! - `str` values are Rust `String`s, so lone surrogates cannot be represented. +//! - [`pickle::loads`] decodes `tuple`, `set`, and `frozenset` as lists. + +mod error; +pub mod json; +pub mod literal; +pub mod pickle; +pub mod repr; +pub mod truthy; +mod value; + +pub use error::Error; +pub use num_bigint::BigInt; +pub use value::Value; + +/// Nesting limit for the decoders, which recurse. It keeps untrusted persisted data from +/// overflowing the Rust stack, and is deliberately stricter than CPython, whose parser takes +/// about 200 nested brackets and whose unpickler has no limit. +pub const MAX_DEPTH: usize = 128; diff --git a/litellm-rust/crates/python-compat/src/literal.rs b/litellm-rust/crates/python-compat/src/literal.rs new file mode 100644 index 00000000000..12e9036572f --- /dev/null +++ b/litellm-rust/crates/python-compat/src/literal.rs @@ -0,0 +1,745 @@ +//! `ast.literal_eval(text)`, as a single-pass recursive-descent parser. +//! +//! Tokens follow CPython's tokenizer (string prefixes, escapes, implicit concatenation, +//! numeric underscores and radixes, comments, line continuations). Expressions follow +//! `ast.literal_eval`'s evaluator: +//! +//! - one unary `+`/`-`, applied only to a numeric constant (`-(1)` is fine, `--1` is not) +//! - `a + b` / `a - b` only as a signed real plus or minus a complex constant, with 3.14's +//! mixed-mode rules (`1 - 0j` is `(1-0j)`) +//! - parentheses group without making a tuple; `set()` is the only call +//! - dict keys and set members are deduplicated with Python equality (`1 == 1.0 == True`) +//! +//! Not supported, each pinned by a fixture: `\N{NAME}` escapes, `...`, and escapes that +//! produce lone surrogates. + +use std::collections::{HashMap, hash_map::Entry}; + +use num_bigint::BigInt; +use num_traits::{FromPrimitive, ToPrimitive}; + +use crate::{Error, MAX_DEPTH, Value}; + +pub fn literal_eval(text: &str) -> Result { + let mut parser = Parser { + bytes: text.trim_start_matches([' ', '\t']).as_bytes(), + offset: text.len() - text.trim_start_matches([' ', '\t']).len(), + pos: 0, + depth: 0, + brackets: 0, + }; + parser.skip_leading_lines()?; + let value = parser.top_level()?.value; + parser.skip_trivia(true); + if parser.pos != parser.bytes.len() { + return Err(parser.error()); + } + Ok(value) +} + +/// How a parsed term may take part in `+`/`-`, per `ast.literal_eval`'s `_convert_num` +/// (`Constant`) and `_convert_signed_num` (`Signed`). `Other` is any other node. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Constant, + Signed, + Other, +} + +struct Term { + value: Value, + kind: Kind, +} + +impl Term { + fn other(value: Value) -> Self { + Self { + value, + kind: Kind::Other, + } + } + + fn is_number(&self) -> bool { + matches!( + self.value, + Value::Int(_) | Value::Float(_) | Value::Complex { .. } + ) + } +} + +struct Parser<'a> { + bytes: &'a [u8], + /// Bytes stripped before `bytes` starts, so errors report offsets into the input. + offset: usize, + pos: usize, + depth: usize, + /// Open brackets: newlines are insignificant only inside them. + brackets: usize, +} + +impl Parser<'_> { + fn error(&self) -> Error { + Error::InvalidLiteral(self.offset + self.pos) + } + + fn peek(&self) -> Option { + self.bytes.get(self.pos).copied() + } + + fn peek_at(&self, ahead: usize) -> Option { + self.bytes.get(self.pos + ahead).copied() + } + + fn expect(&mut self, byte: u8) -> Result<(), Error> { + if self.peek() != Some(byte) { + return Err(self.error()); + } + self.pos += 1; + Ok(()) + } + + fn enter(&mut self) -> Result<(), Error> { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err(Error::TooDeep); + } + Ok(()) + } + + /// Whitespace, comments, and backslash continuations; newlines too when `newlines`. + fn skip_trivia(&mut self, newlines: bool) { + while let Some(byte) = self.peek() { + match byte { + b' ' | b'\t' | b'\x0c' => self.pos += 1, + b'#' => { + while !matches!(self.peek(), None | Some(b'\n' | b'\r')) { + self.pos += 1; + } + } + // A continuation joins two lines; one that ends the input is an EOF error. + b'\\' if matches!(self.peek_at(1), Some(b'\n' | b'\r')) => { + let len = match (self.peek_at(1), self.peek_at(2)) { + (Some(b'\r'), Some(b'\n')) => 3, + _ => 2, + }; + if self.pos + len >= self.bytes.len() { + break; + } + self.pos += len; + } + b'\n' | b'\r' if newlines => self.pos += 1, + _ => break, + } + } + } + + /// Blank and comment-only lines may precede the expression, whose own line must not be + /// indented (CPython raises `IndentationError`). + fn skip_leading_lines(&mut self) -> Result<(), Error> { + loop { + let line_start = self.pos; + self.skip_trivia(false); + match self.peek() { + Some(b'\n' | b'\r') => self.pos += 1, + Some(_) if line_start > 0 && self.pos > line_start => { + self.pos = line_start; + return Err(self.error()); + } + _ => return Ok(()), + } + } + } + + fn at_logical_line_end(&self) -> bool { + matches!(self.peek(), None | Some(b'\n' | b'\r')) + } + + /// The `eval` input: an expression, or a tuple without parentheses. + fn top_level(&mut self) -> Result { + let first = self.expression()?; + self.skip_trivia(false); + if self.peek() != Some(b',') { + return Ok(first); + } + let mut values = vec![first.value]; + while self.peek() == Some(b',') { + self.pos += 1; + self.skip_trivia(false); + if self.at_logical_line_end() { + break; + } + values.push(self.expression()?.value); + self.skip_trivia(false); + } + Ok(Term::other(Value::Tuple(values))) + } + + /// A sum of unary terms, checked as `ast.literal_eval` checks `BinOp`. + fn expression(&mut self) -> Result { + let mut left = self.unary()?; + loop { + self.skip_trivia(self.brackets > 0); + let subtract = match self.peek() { + Some(b'+') => false, + Some(b'-') => true, + _ => return Ok(left), + }; + let at = self.pos; + self.pos += 1; + let right = self.unary()?; + left = complex_sum(left, subtract, right) + .ok_or(Error::InvalidLiteral(self.offset + at))?; + } + } + + fn unary(&mut self) -> Result { + self.skip_trivia(self.brackets > 0); + let negative = match self.peek() { + Some(b'+') => false, + Some(b'-') => true, + _ => return self.primary(), + }; + let at = self.pos; + self.pos += 1; + self.enter()?; + let operand = self.unary()?; + self.depth -= 1; + if operand.kind != Kind::Constant || !operand.is_number() { + return Err(Error::InvalidLiteral(self.offset + at)); + } + let value = if !negative { + operand.value + } else { + match operand.value { + Value::Int(value) => Value::Int(-value), + Value::Float(value) => Value::Float(-value), + Value::Complex { re, im } => Value::Complex { re: -re, im: -im }, + _ => unreachable!("checked numeric above"), + } + }; + Ok(Term { + value, + kind: Kind::Signed, + }) + } + + fn primary(&mut self) -> Result { + match self.peek() { + Some(b'(') => self.parenthesized(), + Some(b'[') => self.list(), + Some(b'{') => self.braced(), + Some(b'0'..=b'9') => self.number(), + Some(b'.') if matches!(self.peek_at(1), Some(b'0'..=b'9')) => self.number(), + Some(b'\'' | b'"') => self.strings(), + Some(byte) if byte.is_ascii_alphabetic() || byte == b'_' => { + if self.string_prefix_len().is_some() { + return self.strings(); + } + self.name() + } + _ => Err(self.error()), + } + } + + fn open(&mut self) -> Result<(), Error> { + self.enter()?; + self.brackets += 1; + self.pos += 1; + Ok(()) + } + + fn close(&mut self, byte: u8) -> Result<(), Error> { + self.skip_trivia(true); + self.expect(byte)?; + self.brackets -= 1; + self.depth -= 1; + Ok(()) + } + + /// Comma-separated expressions up to `close`, with an optional trailing comma. + fn elements(&mut self, close: u8) -> Result, Error> { + let mut values = Vec::new(); + loop { + self.skip_trivia(true); + if self.peek() == Some(close) { + return Ok(values); + } + values.push(self.expression()?.value); + self.skip_trivia(true); + if self.peek() != Some(b',') { + return Ok(values); + } + self.pos += 1; + } + } + + fn parenthesized(&mut self) -> Result { + self.open()?; + self.skip_trivia(true); + if self.peek() == Some(b')') { + self.close(b')')?; + return Ok(Term::other(Value::Tuple(Vec::new()))); + } + let first = self.expression()?; + self.skip_trivia(true); + if self.peek() != Some(b',') { + self.close(b')')?; + return Ok(first); + } + self.pos += 1; + let mut values = vec![first.value]; + values.extend(self.elements(b')')?); + self.close(b')')?; + Ok(Term::other(Value::Tuple(values))) + } + + fn list(&mut self) -> Result { + self.open()?; + let values = self.elements(b']')?; + self.close(b']')?; + Ok(Term::other(Value::List(values))) + } + + fn braced(&mut self) -> Result { + self.open()?; + self.skip_trivia(true); + if self.peek() == Some(b'}') { + self.close(b'}')?; + return Ok(Term::other(Value::Dict(Vec::new()))); + } + let first = self.expression()?.value; + self.skip_trivia(true); + if self.peek() != Some(b':') { + let mut members = UniqueValues::default(); + members.insert(first, None)?; + if self.peek() == Some(b',') { + self.pos += 1; + for member in self.elements(b'}')? { + members.insert(member, None)?; + } + } + self.close(b'}')?; + return Ok(Term::other(Value::Set(members.keys))); + } + let mut entries = UniqueValues::default(); + let mut key = first; + loop { + self.expect(b':')?; + let value = self.expression()?.value; + entries.insert(key, Some(value))?; + self.skip_trivia(true); + if self.peek() != Some(b',') { + break; + } + self.pos += 1; + self.skip_trivia(true); + if self.peek() == Some(b'}') { + break; + } + key = self.expression()?.value; + self.skip_trivia(true); + } + self.close(b'}')?; + Ok(Term::other(Value::Dict(entries.into_entries()))) + } + + fn identifier(&mut self) -> &[u8] { + let start = self.pos; + while matches!(self.peek(), Some(byte) if byte.is_ascii_alphanumeric() || byte == b'_') { + self.pos += 1; + } + &self.bytes[start..self.pos] + } + + fn name(&mut self) -> Result { + let at = self.pos; + let value = match self.identifier() { + b"True" => Value::Bool(true), + b"False" => Value::Bool(false), + b"None" => Value::None, + b"set" => { + self.skip_trivia(self.brackets > 0); + self.expect(b'(')?; + self.skip_trivia(true); + self.expect(b')')?; + return Ok(Term::other(Value::Set(Vec::new()))); + } + _ => return Err(Error::InvalidLiteral(self.offset + at)), + }; + Ok(Term { + value, + kind: Kind::Constant, + }) + } + + /// Digits with single underscores between them, as CPython's `digitpart`. + fn digits(&mut self, radix: u32, out: &mut String) -> Result<(), Error> { + let start = out.len(); + loop { + match self.peek() { + Some(byte) if (byte as char).is_digit(radix) => { + out.push(byte as char); + self.pos += 1; + } + Some(b'_') + if out.len() > start + && matches!(self.peek_at(1), Some(next) if (next as char).is_digit(radix)) => + { + self.pos += 1; + } + _ => break, + } + } + if out.len() == start { + return Err(self.error()); + } + Ok(()) + } + + fn number(&mut self) -> Result { + let start = self.pos; + let radix = match ( + self.peek(), + self.peek_at(1).map(|byte| byte.to_ascii_lowercase()), + ) { + (Some(b'0'), Some(b'x')) => Some(16), + (Some(b'0'), Some(b'o')) => Some(8), + (Some(b'0'), Some(b'b')) => Some(2), + _ => None, + }; + let mut text = String::new(); + let value = if let Some(radix) = radix { + self.pos += 2; + if self.peek() == Some(b'_') { + self.pos += 1; + } + self.digits(radix, &mut text)?; + Value::Int(BigInt::parse_bytes(text.as_bytes(), radix).ok_or(self.error())?) + } else { + let mut is_float = false; + if self.peek() != Some(b'.') { + self.digits(10, &mut text)?; + } + let integer_digits = text.clone(); + if self.peek() == Some(b'.') { + is_float = true; + self.pos += 1; + text.push('.'); + if matches!(self.peek(), Some(b'0'..=b'9')) { + self.digits(10, &mut text)?; + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + is_float = true; + self.pos += 1; + text.push('e'); + if let Some(sign @ (b'+' | b'-')) = self.peek() { + text.push(sign as char); + self.pos += 1; + } + self.digits(10, &mut text)?; + } + if matches!(self.peek(), Some(b'j' | b'J')) { + self.pos += 1; + let im = text.parse::().map_err(|_| self.error())?; + Value::Complex { re: 0.0, im } + } else if is_float { + Value::Float(text.parse::().map_err(|_| self.error())?) + } else { + if integer_digits.len() > 1 + && integer_digits.starts_with('0') + && integer_digits.bytes().any(|digit| digit != b'0') + { + return Err(Error::InvalidLiteral(self.offset + start)); + } + Value::Int(BigInt::parse_bytes(integer_digits.as_bytes(), 10).ok_or(self.error())?) + } + }; + if matches!(self.peek(), Some(byte) if byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'.') + { + return Err(self.error()); + } + Ok(Term { + value, + kind: Kind::Constant, + }) + } + + /// The length of a valid string prefix (`r`, `u`, `b`, `br`, `rb`, any case) directly + /// followed by a quote. + fn string_prefix_len(&self) -> Option { + let mut len = 0; + while matches!(self.peek_at(len), Some(byte) if byte.is_ascii_alphabetic()) && len < 3 { + len += 1; + } + if !matches!(self.peek_at(len), Some(b'\'' | b'"')) { + return None; + } + let prefix: Vec = self.bytes[self.pos..self.pos + len] + .iter() + .map(u8::to_ascii_lowercase) + .collect(); + matches!(prefix.as_slice(), b"" | b"r" | b"u" | b"b" | b"br" | b"rb").then_some(len) + } + + /// Adjacent string literals concatenate; `str` and `bytes` cannot mix. + fn strings(&mut self) -> Result { + let mut text: Option = None; + let mut bytes: Option> = None; + loop { + let at = self.pos; + let Some(prefix_len) = self.string_prefix_len() else { + break; + }; + let prefix = &self.bytes[self.pos..self.pos + prefix_len]; + let raw = prefix.iter().any(|byte| byte.eq_ignore_ascii_case(&b'r')); + let is_bytes = prefix.iter().any(|byte| byte.eq_ignore_ascii_case(&b'b')); + self.pos += prefix_len; + let mut out = Vec::new(); + self.string_body(raw, is_bytes, &mut out)?; + if is_bytes { + if text.is_some() { + return Err(Error::InvalidLiteral(self.offset + at)); + } + bytes.get_or_insert_with(Vec::new).extend(out); + } else { + if bytes.is_some() { + return Err(Error::InvalidLiteral(self.offset + at)); + } + let piece = + String::from_utf8(out).map_err(|_| Error::InvalidLiteral(self.offset + at))?; + text.get_or_insert_with(String::new).push_str(&piece); + } + self.skip_trivia(self.brackets > 0); + } + let value = match (text, bytes) { + (Some(text), None) => Value::Str(text), + (None, Some(bytes)) => Value::Bytes(bytes), + _ => return Err(self.error()), + }; + Ok(Term { + value, + kind: Kind::Constant, + }) + } + + /// One quoted body, decoded into UTF-8 (`str`) or raw bytes (`bytes`). + fn string_body(&mut self, raw: bool, is_bytes: bool, out: &mut Vec) -> Result<(), Error> { + let quote = self.bytes[self.pos]; + let triple = self.peek_at(1) == Some(quote) && self.peek_at(2) == Some(quote); + self.pos += if triple { 3 } else { 1 }; + loop { + let Some(byte) = self.peek() else { + return Err(self.error()); + }; + if byte == quote + && (!triple || (self.peek_at(1) == Some(quote) && self.peek_at(2) == Some(quote))) + { + self.pos += if triple { 3 } else { 1 }; + return Ok(()); + } + match byte { + b'\n' | b'\r' if !triple => return Err(self.error()), + b'\\' if raw => { + let Some(next) = self.peek_at(1) else { + return Err(self.error()); + }; + out.push(b'\\'); + self.pos += 1; + if next == b'\n' || next == b'\r' || next == quote || next == b'\\' { + out.push(next); + self.pos += 1; + } + } + b'\\' => { + self.pos += 1; + self.escape(is_bytes, out)?; + } + byte if is_bytes && !byte.is_ascii() => return Err(self.error()), + byte => { + out.push(byte); + self.pos += 1; + } + } + } + } + + fn escape(&mut self, is_bytes: bool, out: &mut Vec) -> Result<(), Error> { + let Some(byte) = self.peek() else { + return Err(self.error()); + }; + self.pos += 1; + let simple = match byte { + b'\n' => return Ok(()), + b'\r' => { + if self.peek() == Some(b'\n') { + self.pos += 1; + } + return Ok(()); + } + b'\\' | b'\'' | b'"' => byte, + b'a' => 0x07, + b'b' => 0x08, + b'f' => 0x0c, + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'v' => 0x0b, + b'0'..=b'7' => { + let mut code = u32::from(byte - b'0'); + for _ in 0..2 { + match self.peek() { + Some(digit @ b'0'..=b'7') => { + code = code * 8 + u32::from(digit - b'0'); + self.pos += 1; + } + _ => break, + } + } + // Bytes keep the low eight bits of `\400`-`\777`, as CPython does. + return self.push_code(if is_bytes { code & 0xff } else { code }, is_bytes, out); + } + b'x' => { + let code = self.hex(2)?; + return self.push_code(code, is_bytes, out); + } + b'u' if !is_bytes => { + let code = self.hex(4)?; + return self.push_code(code, is_bytes, out); + } + b'U' if !is_bytes => { + let code = self.hex(8)?; + return self.push_code(code, is_bytes, out); + } + b'N' if !is_bytes => return Err(self.error()), + _ => { + // Unknown escapes keep the backslash (a `SyntaxWarning` in CPython). + out.push(b'\\'); + self.pos -= 1; + return Ok(()); + } + }; + out.push(simple); + Ok(()) + } + + fn hex(&mut self, count: usize) -> Result { + let mut code = 0u32; + for _ in 0..count { + let digit = self + .peek() + .and_then(|byte| (byte as char).to_digit(16)) + .ok_or(self.error())?; + code = code * 16 + digit; + self.pos += 1; + } + Ok(code) + } + + fn push_code(&self, code: u32, is_bytes: bool, out: &mut Vec) -> Result<(), Error> { + if is_bytes { + out.push(u8::try_from(code).map_err(|_| self.error())?); + return Ok(()); + } + let ch = char::from_u32(code).ok_or(self.error())?; + let mut buffer = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut buffer).as_bytes()); + Ok(()) + } +} + +/// `left + right` or `left - right` as `ast.literal_eval` permits: a signed real on the +/// left and an unsigned complex constant on the right, combined with CPython 3.14's +/// mixed-mode rules, which leave the imaginary part untouched by the real operand. +fn complex_sum(left: Term, subtract: bool, right: Term) -> Option { + if left.kind == Kind::Other || right.kind != Kind::Constant { + return None; + } + let real = match &left.value { + Value::Int(value) => value.to_f64().filter(|value| value.is_finite())?, + Value::Float(value) => *value, + _ => return None, + }; + let Value::Complex { re, im } = right.value else { + return None; + }; + let value = if subtract { + Value::Complex { + re: real - re, + im: -im, + } + } else { + Value::Complex { re: real + re, im } + }; + Some(Term::other(value)) +} + +/// Python equality for hashable literal values: numbers compare by value across `bool`, +/// `int`, `float`, and `complex`, so `1`, `1.0`, `True`, and `(1+0j)` are one key. +#[derive(Hash, PartialEq, Eq)] +enum KeyId { + None, + Int(BigInt), + Float(u64), + Complex(u64, u64), + Str(String), + Bytes(Vec), + Tuple(Vec), +} + +fn float_key(value: f64) -> KeyId { + if value.fract() == 0.0 + && let Some(integer) = BigInt::from_f64(value) + { + return KeyId::Int(integer); + } + KeyId::Float(value.to_bits()) +} + +fn key_id(value: &Value) -> Result { + Ok(match value { + Value::None => KeyId::None, + Value::Bool(value) => KeyId::Int(BigInt::from(u8::from(*value))), + Value::Int(value) => KeyId::Int(value.clone()), + Value::Float(value) => float_key(*value), + Value::Complex { re, im } if *im == 0.0 => float_key(*re), + Value::Complex { re, im } => KeyId::Complex((re + 0.0).to_bits(), (im + 0.0).to_bits()), + Value::Str(text) => KeyId::Str(text.clone()), + Value::Bytes(bytes) => KeyId::Bytes(bytes.clone()), + Value::Tuple(values) => KeyId::Tuple(values.iter().map(key_id).collect::>()?), + value @ (Value::List(_) | Value::Dict(_) | Value::Set(_)) => { + return Err(Error::Unhashable(value.type_name())); + } + }) +} + +/// Dict entries or set members in first-seen order: a repeated key keeps its first +/// position and, for dicts, takes the latest value. +#[derive(Default)] +struct UniqueValues { + keys: Vec, + values: Vec, + index: HashMap, +} + +impl UniqueValues { + fn insert(&mut self, key: Value, value: Option) -> Result<(), Error> { + match self.index.entry(key_id(&key)?) { + Entry::Occupied(slot) => { + if let Some(value) = value { + self.values[*slot.get()] = value; + } + } + Entry::Vacant(slot) => { + slot.insert(self.keys.len()); + self.keys.push(key); + self.values.extend(value); + } + } + Ok(()) + } + + fn into_entries(self) -> Vec<(Value, Value)> { + self.keys.into_iter().zip(self.values).collect() + } +} diff --git a/litellm-rust/crates/python-compat/src/pickle.rs b/litellm-rust/crates/python-compat/src/pickle.rs new file mode 100644 index 00000000000..bff7442dc6b --- /dev/null +++ b/litellm-rust/crates/python-compat/src/pickle.rs @@ -0,0 +1,187 @@ +//! `pickle.loads` and `pickle.dumps` for plain data, as diskcache stores LiteLLM values. +//! +//! Both directions go through `serde-pickle`'s serde interface rather than +//! `serde_pickle::Value`, because that value type keeps dicts in a `BTreeMap` and would +//! reorder keys. The serde interface keeps insertion order, at the cost of reporting +//! `tuple`, `set`, and `frozenset` as sequences: [`loads`] decodes all three as lists. +//! Python objects that need a class (`GLOBAL`/`REDUCE`) and recursive structures fail. + +use std::fmt; + +use serde::{ + Deserializer, Serialize, Serializer, + de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}, + ser::{SerializeMap, SerializeSeq, SerializeTuple}, +}; +use serde_pickle::{DeOptions, SerOptions}; + +use crate::{Error, MAX_DEPTH, Value}; + +/// `pickle.loads(data)` for any protocol from 0 to 5. +pub fn loads(data: &[u8]) -> Result { + let mut deserializer = serde_pickle::Deserializer::new(data, DeOptions::new()); + let value = Seed { depth: 0 } + .deserialize(&mut deserializer) + .map_err(|error| Error::InvalidPickle(error.to_string()))?; + deserializer + .end() + .map_err(|error| Error::InvalidPickle(error.to_string()))?; + Ok(value) +} + +/// `pickle.dumps(value, protocol=3)`. Every Python 3 reads protocol 3, whatever its own +/// default. Sets and complex numbers are rejected rather than silently changing type. +pub fn dumps(value: &Value) -> Result, Error> { + check_picklable(value, 0)?; + serde_pickle::to_vec(&Pickled(value), SerOptions::new()) + .map_err(|error| Error::InvalidPickle(error.to_string())) +} + +fn check_picklable(value: &Value, depth: usize) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(Error::TooDeep); + } + match value { + Value::Int(value) if i64::try_from(value).is_err() => Err(Error::IntegerOutOfRange), + value @ (Value::Set(_) | Value::Complex { .. }) => { + Err(Error::NotPicklable(value.type_name())) + } + Value::List(values) | Value::Tuple(values) => values + .iter() + .try_for_each(|value| check_picklable(value, depth + 1)), + Value::Dict(entries) => entries.iter().try_for_each(|(key, value)| { + check_picklable(key, depth + 1)?; + check_picklable(value, depth + 1) + }), + _ => Ok(()), + } +} + +struct Pickled<'a>(&'a Value); + +impl Serialize for Pickled<'_> { + fn serialize(&self, serializer: S) -> Result { + match self.0 { + Value::None => serializer.serialize_unit(), + Value::Bool(value) => serializer.serialize_bool(*value), + Value::Int(value) => { + let value = i64::try_from(value) + .map_err(|_| serde::ser::Error::custom("integer out of i64 range"))?; + serializer.serialize_i64(value) + } + Value::Float(value) => serializer.serialize_f64(*value), + Value::Str(text) => serializer.serialize_str(text), + Value::Bytes(bytes) => serializer.serialize_bytes(bytes), + Value::List(values) => { + let mut seq = serializer.serialize_seq(Some(values.len()))?; + for value in values { + seq.serialize_element(&Pickled(value))?; + } + seq.end() + } + Value::Tuple(values) => { + let mut tuple = serializer.serialize_tuple(values.len())?; + for value in values { + tuple.serialize_element(&Pickled(value))?; + } + tuple.end() + } + Value::Dict(entries) => { + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + map.serialize_entry(&Pickled(key), &Pickled(value))?; + } + map.end() + } + value @ (Value::Set(_) | Value::Complex { .. }) => Err(serde::ser::Error::custom( + format!("{} cannot be pickled as plain data", value.type_name()), + )), + } + } +} + +#[derive(Clone, Copy)] +struct Seed { + depth: usize, +} + +impl Seed { + fn child(self) -> Result { + if self.depth >= MAX_DEPTH { + return Err(E::custom(Error::TooDeep)); + } + Ok(Self { + depth: self.depth + 1, + }) + } +} + +impl<'de> DeserializeSeed<'de> for Seed { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for Seed { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a plain Python data value") + } + + fn visit_unit(self) -> Result { + Ok(Value::None) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Int(value.into())) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Int(value.into())) + } + + fn visit_f64(self, value: f64) -> Result { + Ok(Value::Float(value)) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::Str(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::Str(value)) + } + + fn visit_bytes(self, value: &[u8]) -> Result { + Ok(Value::Bytes(value.to_vec())) + } + + fn visit_byte_buf(self, value: Vec) -> Result { + Ok(Value::Bytes(value)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let child = self.child()?; + let mut values = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(4096)); + while let Some(value) = seq.next_element_seed(child)? { + values.push(value); + } + Ok(Value::List(values)) + } + + fn visit_map>(self, mut map: A) -> Result { + let child = self.child()?; + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0).min(4096)); + while let Some(key) = map.next_key_seed(child)? { + entries.push((key, map.next_value_seed(child)?)); + } + Ok(Value::Dict(entries)) + } +} diff --git a/litellm-rust/crates/python-compat/src/repr.rs b/litellm-rust/crates/python-compat/src/repr.rs new file mode 100644 index 00000000000..1cf20d87716 --- /dev/null +++ b/litellm-rust/crates/python-compat/src/repr.rs @@ -0,0 +1,237 @@ +//! `repr(value)` and `str(value)` byte for byte. +//! +//! LiteLLM hashes `str(value)` into cache keys and writes `str(dict)` into Redis, so these +//! strings are persisted identifiers rather than display text: every quote choice, escape, +//! and float digit must match CPython. + +use std::fmt::Write; + +use crate::Value; + +/// Generated by `scripts/generate_nonprintable.py`; see `generated/nonprintable.rs`. +mod nonprintable { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/generated/nonprintable.rs" + )); +} + +/// Unicode version of the printable-character table, from the Python that generated it. +pub const UNICODE_VERSION: &str = nonprintable::UNICODE_VERSION; + +/// `repr(value)`. +pub fn repr(value: &Value) -> String { + let mut out = String::new(); + write_repr(&mut out, value); + out +} + +/// `str(value)`: a string's own contents, and `repr` for every other value. +pub fn to_str(value: &Value) -> String { + match value { + Value::Str(text) => text.clone(), + value => repr(value), + } +} + +/// `repr(float)`, the shortest round-trip form with CPython's exponent thresholds. +pub fn float_repr(value: f64) -> String { + format_float(value, true) +} + +fn write_repr(out: &mut String, value: &Value) { + match value { + Value::None => out.push_str("None"), + Value::Bool(true) => out.push_str("True"), + Value::Bool(false) => out.push_str("False"), + Value::Int(value) => { + let _ = write!(out, "{value}"); + } + Value::Float(value) => out.push_str(&float_repr(*value)), + Value::Complex { re, im } => write_complex(out, *re, *im), + Value::Str(text) => write_str(out, text), + Value::Bytes(bytes) => write_bytes(out, bytes), + Value::List(values) => write_sequence(out, '[', values, ']'), + Value::Tuple(values) if values.len() == 1 => { + out.push('('); + write_repr(out, &values[0]); + out.push_str(",)"); + } + Value::Tuple(values) => write_sequence(out, '(', values, ')'), + Value::Set(values) if values.is_empty() => out.push_str("set()"), + Value::Set(values) => write_sequence(out, '{', values, '}'), + Value::Dict(entries) => { + out.push('{'); + for (index, (key, value)) in entries.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + write_repr(out, key); + out.push_str(": "); + write_repr(out, value); + } + out.push('}'); + } + } +} + +fn write_sequence(out: &mut String, open: char, values: &[Value], close: char) { + out.push(open); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push_str(", "); + } + write_repr(out, value); + } + out.push(close); +} + +/// `complex.__repr__`: a `+0.0` real part prints only the imaginary part, without parens. +fn write_complex(out: &mut String, re: f64, im: f64) { + if re == 0.0 && re.is_sign_positive() { + out.push_str(&format_float(im, false)); + out.push('j'); + return; + } + out.push('('); + out.push_str(&format_float(re, false)); + let im_text = format_float(im, false); + if !im_text.starts_with('-') { + out.push('+'); + } + out.push_str(&im_text); + out.push_str("j)"); +} + +/// `PyOS_double_to_string(value, 'r', 0, flags)`: scientific notation below 1e-4 and from +/// 1e16 up, with a signed exponent of at least two digits. `add_dot_0` is +/// `Py_DTSF_ADD_DOT_0`, which `float` sets and `complex` does not. +fn format_float(value: f64, add_dot_0: bool) -> String { + if value.is_nan() { + return "nan".to_owned(); + } + if value.is_infinite() { + return if value > 0.0 { "inf" } else { "-inf" }.to_owned(); + } + // Rust's `{:e}` prints the shortest round-trip digits, like CPython's 'r' mode. + let scientific = format!("{value:e}"); + let (mantissa, exponent) = scientific + .split_once('e') + .expect("`{:e}` always prints an exponent"); + let exponent: i32 = exponent.parse().expect("`{:e}` exponent is an integer"); + let (sign, mantissa) = match mantissa.strip_prefix('-') { + Some(mantissa) => ("-", mantissa), + None => ("", mantissa), + }; + let digits: String = mantissa.chars().filter(|ch| *ch != '.').collect(); + + let mut out = String::from(sign); + if !(-4..16).contains(&exponent) { + out.push_str(&digits[..1]); + if digits.len() > 1 { + out.push('.'); + out.push_str(&digits[1..]); + } + let _ = write!( + out, + "e{}{:02}", + if exponent < 0 { '-' } else { '+' }, + exponent.unsigned_abs() + ); + } else if exponent < 0 { + out.push_str("0."); + out.extend(std::iter::repeat_n('0', (-exponent - 1) as usize)); + out.push_str(&digits); + } else { + let integer_digits = exponent as usize + 1; + if digits.len() > integer_digits { + out.push_str(&digits[..integer_digits]); + out.push('.'); + out.push_str(&digits[integer_digits..]); + } else { + out.push_str(&digits); + out.extend(std::iter::repeat_n('0', integer_digits - digits.len())); + if add_dot_0 { + out.push_str(".0"); + } + } + } + out +} + +/// `unicode_repr`: single quotes unless the text has a `'` and no `"`. Printable non-ASCII +/// stays literal; everything `str.isprintable()` rejects is escaped. +fn write_str(out: &mut String, text: &str) { + let quote = if text.contains('\'') && !text.contains('"') { + '"' + } else { + '\'' + }; + out.push(quote); + for ch in text.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + ch if ch == quote => { + out.push('\\'); + out.push(ch); + } + ch if is_printable(ch) => out.push(ch), + ch => { + let code = ch as u32; + let _ = match code { + 0..=0xff => write!(out, "\\x{code:02x}"), + 0x100..=0xffff => write!(out, "\\u{code:04x}"), + _ => write!(out, "\\U{code:08x}"), + }; + } + } + } + out.push(quote); +} + +/// `bytes.__repr__`: the same quote rule as `str`, with every byte outside printable ASCII +/// escaped as `\xhh`. +fn write_bytes(out: &mut String, bytes: &[u8]) { + let quote = if bytes.contains(&b'\'') && !bytes.contains(&b'"') { + b'"' + } else { + b'\'' + }; + out.push('b'); + out.push(quote as char); + for &byte in bytes { + match byte { + b'\\' => out.push_str("\\\\"), + b'\t' => out.push_str("\\t"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + byte if byte == quote => { + out.push('\\'); + out.push(byte as char); + } + 0x20..=0x7e => out.push(byte as char), + byte => { + let _ = write!(out, "\\x{byte:02x}"); + } + } + } + out.push(quote as char); +} + +fn is_printable(ch: char) -> bool { + let code = ch as u32; + nonprintable::NONPRINTABLE + .binary_search_by(|&(low, high)| { + if high < code { + std::cmp::Ordering::Less + } else if low > code { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }) + .is_err() +} diff --git a/litellm-rust/crates/python-compat/src/truthy.rs b/litellm-rust/crates/python-compat/src/truthy.rs new file mode 100644 index 00000000000..af747202572 --- /dev/null +++ b/litellm-rust/crates/python-compat/src/truthy.rs @@ -0,0 +1,18 @@ +use num_bigint::Sign; + +use crate::Value; + +/// `bool(value)` for plain data: `None`, `False`, zero, and empty containers are false. +pub fn truthy(value: &Value) -> bool { + match value { + Value::None => false, + Value::Bool(value) => *value, + Value::Int(value) => value.sign() != Sign::NoSign, + Value::Float(value) => *value != 0.0, + Value::Complex { re, im } => *re != 0.0 || *im != 0.0, + Value::Str(value) => !value.is_empty(), + Value::Bytes(value) => !value.is_empty(), + Value::Tuple(values) | Value::List(values) | Value::Set(values) => !values.is_empty(), + Value::Dict(entries) => !entries.is_empty(), + } +} diff --git a/litellm-rust/crates/python-compat/src/value.rs b/litellm-rust/crates/python-compat/src/value.rs new file mode 100644 index 00000000000..b1397190638 --- /dev/null +++ b/litellm-rust/crates/python-compat/src/value.rs @@ -0,0 +1,53 @@ +use num_bigint::BigInt; + +/// A Python value built only from literals: what `ast.literal_eval` can return. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + None, + Bool(bool), + Int(BigInt), + Float(f64), + Complex { + re: f64, + im: f64, + }, + Str(String), + Bytes(Vec), + Tuple(Vec), + List(Vec), + /// Insertion-ordered, with Python's key equality already applied. + Dict(Vec<(Value, Value)>), + /// Literal order, with Python's member equality already applied. + Set(Vec), +} + +impl Value { + /// Python's type name, as it appears in `TypeError` messages. + pub fn type_name(&self) -> &'static str { + match self { + Value::None => "NoneType", + Value::Bool(_) => "bool", + Value::Int(_) => "int", + Value::Float(_) => "float", + Value::Complex { .. } => "complex", + Value::Str(_) => "str", + Value::Bytes(_) => "bytes", + Value::Tuple(_) => "tuple", + Value::List(_) => "list", + Value::Dict(_) => "dict", + Value::Set(_) => "set", + } + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Value::Int(value.into()) + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Value::Str(value.to_owned()) + } +} diff --git a/litellm-rust/crates/python-compat/tests/fixtures.rs b/litellm-rust/crates/python-compat/tests/fixtures.rs new file mode 100644 index 00000000000..9c202fdc5f1 --- /dev/null +++ b/litellm-rust/crates/python-compat/tests/fixtures.rs @@ -0,0 +1,343 @@ +//! Replays `generated/values.json`, which CPython wrote with `scripts/generate_fixtures.py`. + +use std::{collections::BTreeMap, fs::File, io::Write}; + +use litellm_python_compat::{ + Value, json, + literal::literal_eval, + pickle, + repr::{repr, to_str}, + truthy::truthy, +}; +use rstest::{fixture, rstest}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Fixtures { + rows: Vec, + sources: Vec, +} + +/// `ast.literal_eval(source)`: the `repr` of its result, or the exception class it raised. +#[derive(Deserialize)] +struct Source { + name: String, + source: String, + repr: Option, + error: Option, +} + +#[derive(Deserialize)] +struct Row { + name: String, + source: String, + literal: bool, + plain: bool, + repr: String, + str: String, + truthy: bool, + json: Option, + json_error: Option, + pickle: Option>, + view: Option, +} + +/// Parsed once for the whole test binary. +#[fixture] +#[once] +fn fixtures() -> Fixtures { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/generated/values.json" + ))) + .expect("values.json matches the fixture schema") +} + +/// Accepted differences from CPython: `(fixture source, check prefix, reason)`. Each entry +/// must still differ, so a dependency fix that removes one fails the test until it is deleted. +const KNOWN: &[(&str, &str, &str)] = &[ + ( + "...", + "literal_eval source", + "`Ellipsis` is not part of the data model", + ), + ( + r"'\ud800'", + "literal_eval source", + "a Rust `String` cannot hold a lone surrogate", + ), + ( + r"'\N{BULLET}'", + "literal_eval source", + "`\\N{NAME}` needs the Unicode name table", + ), + ( + "nested_150", + "literal_eval", + "deeper than MAX_DEPTH: rejected for stack safety, where CPython still parses it", + ), + ( + "nested_150", + "pickle.loads", + "deeper than MAX_DEPTH: rejected for stack safety, where CPython has no limit", + ), + ( + "2**64", + "pickle.loads", + "serde-pickle's serde interface stops at i64", + ), + ( + "-(2**70)", + "pickle.loads", + "serde-pickle's serde interface stops at i64", + ), + ( + "2**64", + "pickle.dumps", + "serde-pickle's serde interface stops at i64", + ), + ( + "-(2**70)", + "pickle.dumps", + "serde-pickle's serde interface stops at i64", + ), + ( + "2**64", + "to_json", + "serde_json has no exact form for integers beyond u64", + ), + ( + "-(2**70)", + "to_json", + "serde_json has no exact form for integers beyond i64", + ), + ( + "b''", + "pickle.loads protocol 0", + "protocols 0-2 pickle `b''` as a `bytes()` call", + ), + ( + "b''", + "pickle.loads protocol 1", + "protocols 0-2 pickle `b''` as a `bytes()` call", + ), + ( + "b''", + "pickle.loads protocol 2", + "protocols 0-2 pickle `b''` as a `bytes()` call", + ), +]; + +/// Collects every mismatch so one run reports the whole divergence set. +#[derive(Default)] +struct Mismatches { + unexpected: Vec, + known_seen: Vec, + known_scope: Vec, +} + +impl Mismatches { + fn known(source: &str, what: &str) -> Option { + KNOWN + .iter() + .position(|(known, prefix, _)| *known == source && what.starts_with(prefix)) + } + + fn check(&mut self, row: &Row, what: &str, expected: &str, actual: &str) { + self.check_source(&row.name, what, expected, actual); + } + + /// `name` identifies the fixture row in reports and in [`KNOWN`]. + fn check_source(&mut self, name: &str, what: &str, expected: &str, actual: &str) { + let known = Self::known(name, what); + if let Some(index) = known { + self.known_scope.push(index); + } + if expected == actual { + return; + } + match known { + Some(index) => self.known_seen.push(index), + None => self.unexpected.push(format!( + "{name:?} [{what}]\n python: {expected}\n rust: {actual}" + )), + } + } + + fn finish(self) { + let resolved: Vec<_> = self + .known_scope + .iter() + .filter(|index| !self.known_seen.contains(index)) + .map(|&index| format!("{} [{}]", KNOWN[index].0, KNOWN[index].1)) + .collect(); + assert!( + self.unexpected.is_empty() && resolved.is_empty(), + "{} mismatches with CPython:\n{}\nknown divergences that now match (delete them \ + from KNOWN): {resolved:?}", + self.unexpected.len(), + self.unexpected.join("\n"), + ); + } +} + +fn check_value(mismatches: &mut Mismatches, row: &Row, value: &Value) { + mismatches.check(row, "repr", &row.repr, &repr(value)); + mismatches.check(row, "str", &row.str, &to_str(value)); + mismatches.check( + row, + "bool", + &row.truthy.to_string(), + &truthy(value).to_string(), + ); + let expected = row.json.clone().or_else(|| { + row.json_error + .clone() + .map(|error| format!("error: {error}")) + }); + let actual = match json::dumps(value) { + Ok(text) => text, + Err(error) => format!("error: {error}"), + }; + mismatches.check( + row, + "json.dumps", + expected.as_deref().unwrap_or(""), + &actual, + ); +} + +#[rstest] +fn literal_rows_match_python_repr_str_bool_and_json(fixtures: &Fixtures) { + let mut mismatches = Mismatches::default(); + for row in fixtures.rows.iter().filter(|row| row.literal) { + match literal_eval(&row.repr) { + Ok(value) => check_value(&mut mismatches, row, &value), + Err(error) => mismatches.check(row, "literal_eval", &row.repr, &error.to_string()), + } + } + mismatches.finish(); +} + +/// Errors compare by outcome only: CPython's exception class is not part of the contract. +#[rstest] +fn literal_eval_matches_python_on_source_texts(fixtures: &Fixtures) { + let mut mismatches = Mismatches::default(); + for case in &fixtures.sources { + let expected = match (&case.repr, &case.error) { + (Some(repr), None) => repr.clone(), + (None, Some(_)) => "an error".to_owned(), + _ => panic!("{:?}: a source records a repr or an error", case.source), + }; + let actual = match literal_eval(&case.source) { + Ok(value) => repr(&value), + Err(_) => "an error".to_owned(), + }; + mismatches.check_source(&case.name, "literal_eval source", &expected, &actual); + } + mismatches.finish(); +} + +#[rstest] +fn pickle_loads_matches_python_at_every_protocol(fixtures: &Fixtures) { + let mut mismatches = Mismatches::default(); + for row in &fixtures.rows { + let Some(pickles) = &row.pickle else { continue }; + for (protocol, data) in pickles { + let data = hex::decode(data).expect("fixture pickle is hex"); + let what = format!("pickle.loads protocol {protocol}"); + match (pickle::loads(&data), row.plain) { + (Ok(value), true) => { + let view = row.view.as_deref().expect("picklable rows have a view"); + mismatches.check(row, &what, view, &repr(&value)); + } + (Err(error), true) => { + mismatches.check(row, &what, "a value", &format!("error: {error}")) + } + (Ok(value), false) => { + mismatches.check(row, &what, "a class-reference error", &repr(&value)) + } + (Err(pickle_error), false) => assert!( + matches!(pickle_error, litellm_python_compat::Error::InvalidPickle(_)), + "{}: {pickle_error}", + row.source + ), + } + } + } + mismatches.finish(); +} + +/// Non-finite floats have no literal form; pickle is how Rust receives them. +#[rstest] +fn values_reached_only_through_pickle_match_python(fixtures: &Fixtures) { + let mut mismatches = Mismatches::default(); + for row in fixtures + .rows + .iter() + .filter(|row| !row.literal && row.plain && row.view.as_deref() == Some(&row.repr)) + { + let data = hex::decode(&row.pickle.as_ref().expect("plain rows pickle")["5"]) + .expect("fixture pickle is hex"); + let value = pickle::loads(&data).expect("plain pickle decodes"); + check_value(&mut mismatches, row, &value); + } + mismatches.finish(); +} + +/// Byte equality with CPython is not the contract: CPython adds memo opcodes and picks the +/// smallest integer opcode. `scripts/verify_rust_pickles.py` checks that CPython reads these +/// back; set `PYTHON_COMPAT_RUST_PICKLES` to a file path to export them. +#[rstest] +fn pickle_dumps_round_trips_every_plain_literal(fixtures: &Fixtures) { + // Truncate up front: the verifier must read this run's rows and nothing else. + let mut export = std::env::var_os("PYTHON_COMPAT_RUST_PICKLES") + .map(|path| File::create(path).expect("the export path is writable")); + let mut mismatches = Mismatches::default(); + for row in fixtures + .rows + .iter() + .filter(|row| row.literal && row.plain && row.view.as_deref() == Some(&row.repr)) + { + // Rows past `MAX_DEPTH` are covered by the literal test's own KNOWN entry. + let Ok(value) = literal_eval(&row.repr) else { + continue; + }; + let data = match pickle::dumps(&value) { + Ok(data) => data, + Err(error) => { + mismatches.check(row, "pickle.dumps", "a pickle", &format!("error: {error}")); + continue; + } + }; + let decoded = pickle::loads(&data).expect("rust pickle decodes"); + mismatches.check(row, "pickle round trip", &row.repr, &repr(&decoded)); + if let Some(file) = &mut export { + writeln!(file, "{}\t{}", hex::encode(&data), repr(&value)) + .expect("the export file is writable"); + } + } + mismatches.finish(); +} + +#[rstest] +fn to_json_matches_python_json_round_trip(fixtures: &Fixtures) { + let mut mismatches = Mismatches::default(); + for row in fixtures.rows.iter().filter(|row| row.literal) { + let Some(expected) = &row.json else { continue }; + let Ok(value) = literal_eval(&row.repr) else { + continue; + }; + let expected: serde_json::Value = + serde_json::from_str(expected).expect("python json.dumps output parses"); + match json::to_json(&value) { + Ok(actual) => { + mismatches.check(row, "to_json", &expected.to_string(), &actual.to_string()) + } + Err(error) => { + mismatches.check(row, "to_json", &expected.to_string(), &error.to_string()) + } + } + } + mismatches.finish(); +} diff --git a/litellm-rust/crates/python-compat/tests/limits.rs b/litellm-rust/crates/python-compat/tests/limits.rs new file mode 100644 index 00000000000..c4d931034c4 --- /dev/null +++ b/litellm-rust/crates/python-compat/tests/limits.rs @@ -0,0 +1,122 @@ +use std::time::{Duration, Instant}; + +use litellm_python_compat::{Error, MAX_DEPTH, Value, json, literal::literal_eval, pickle}; +use rstest::{fixture, rstest}; + +/// The bracket pair of one container shape, as `(open, close)`. +#[fixture] +fn shapes() -> [(&'static str, &'static str); 3] { + [("[", "]"), ("{'a': ", "}"), ("(", ",)")] +} + +fn nested_text(open: &str, close: &str, depth: usize) -> String { + format!("{}1{}", open.repeat(depth), close.repeat(depth)) +} + +fn nested_list(depth: usize) -> Value { + (0..depth).fold(Value::from(1), |value, _| Value::List(vec![value])) +} + +/// A protocol 3 pickle of `depth` nested lists around `1`: `EMPTY_LIST` per level, then +/// `BININT1 1`, then `APPEND` per level. Written by hand because `dumps` refuses the depth. +fn nested_list_pickle(depth: usize) -> Vec { + let mut data = vec![0x80, 3]; + data.extend(std::iter::repeat_n(b']', depth)); + data.extend([b'K', 1]); + data.extend(std::iter::repeat_n(b'a', depth)); + data.push(b'.'); + data +} + +#[rstest] +fn literal_eval_accepts_the_limit_and_rejects_past_it(shapes: [(&'static str, &'static str); 3]) { + for (open, close) in shapes { + assert!(literal_eval(&nested_text(open, close, MAX_DEPTH)).is_ok()); + assert!(matches!( + literal_eval(&nested_text(open, close, MAX_DEPTH + 1)), + Err(Error::TooDeep) + )); + } +} + +/// A backtracking parser (the `py_literal` grammar this replaced) doubles per nested level +/// and takes minutes here; the bound is loose enough to survive a slow debug build. +#[rstest] +fn literal_eval_stays_linear_in_depth(shapes: [(&'static str, &'static str); 3]) { + for (open, close) in shapes { + let text = nested_text(open, close, MAX_DEPTH); + let start = Instant::now(); + assert!(literal_eval(&text).is_ok()); + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_millis(50), + "{open} nested {MAX_DEPTH} deep took {elapsed:?}" + ); + } +} + +#[rstest] +fn literal_eval_ignores_brackets_inside_strings() { + let text = format!("'{}'", "[".repeat(MAX_DEPTH + 1)); + assert!(matches!(literal_eval(&text), Ok(Value::Str(_)))); +} + +#[rstest] +fn pickle_nesting_is_bounded_in_both_directions() { + assert_eq!( + pickle::loads(&nested_list_pickle(MAX_DEPTH)).unwrap(), + nested_list(MAX_DEPTH) + ); + assert!(matches!( + pickle::loads(&nested_list_pickle(MAX_DEPTH + 1)), + Err(Error::InvalidPickle(_)) + )); + assert!(pickle::dumps(&nested_list(MAX_DEPTH)).is_ok()); + assert!(matches!( + pickle::dumps(&nested_list(MAX_DEPTH + 2)), + Err(Error::TooDeep) + )); +} + +#[rstest] +#[case("{1, 2}", "set")] +#[case("1+2j", "complex")] +fn pickle_dumps_refuses_types_it_would_change(#[case] source: &str, #[case] type_name: &str) { + let value = literal_eval(source).expect("source is a literal"); + assert!(matches!(pickle::dumps(&value), Err(Error::NotPicklable(name)) if name == type_name)); +} + +#[rstest] +#[case(b"\x80\x02c__builtin__\ncomplex\nq\x00.".to_vec(), "a class reference")] +#[case({ let mut data = pickle::dumps(&Value::from(1)).unwrap(); data.push(b'.'); data }, "trailing data")] +fn pickle_loads_rejects(#[case] data: Vec, #[case] what: &str) { + assert!( + matches!(pickle::loads(&data), Err(Error::InvalidPickle(_))), + "{what} must not decode" + ); +} + +#[rstest] +#[case(Value::Bytes(b"x".to_vec()), "Object of type bytes is not JSON serializable")] +#[case(Value::Set(vec![Value::from(1)]), "Object of type set is not JSON serializable")] +#[case(Value::Complex { re: 1.0, im: 2.0 }, "Object of type complex is not JSON serializable")] +#[case( + Value::Float(f64::NAN), + "Out of range float values are not JSON compliant" +)] +fn to_json_reports_what_python_json_dumps_would_reject( + #[case] value: Value, + #[case] message: &str, +) { + let error = json::to_json(&value).expect_err("value has no serde_json form"); + assert_eq!(error.to_string(), message); +} + +/// `json.dumps` writes the non-finite floats that `to_json` cannot represent. +#[rstest] +#[case(f64::NAN, "NaN")] +#[case(f64::INFINITY, "Infinity")] +#[case(f64::NEG_INFINITY, "-Infinity")] +fn json_dumps_writes_non_finite_floats(#[case] value: f64, #[case] text: &str) { + assert_eq!(json::dumps(&Value::Float(value)).unwrap(), text); +} diff --git a/litellm/caching/caching.py b/litellm/caching/caching.py index 6a98b104221..6f99eb616b0 100644 --- a/litellm/caching/caching.py +++ b/litellm/caching/caching.py @@ -15,7 +15,8 @@ import time import traceback from collections.abc import Mapping from enum import Enum -from typing import Any, Final +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Final from pydantic import BaseModel @@ -38,6 +39,25 @@ from .redis_cluster_cache import RedisClusterCache from .redis_semantic_cache import RedisSemanticCache from .s3_cache import S3Cache +if TYPE_CHECKING: + from litellm.rust_bridge.response_cache import NativeCacheRequest, ResponseCacheRuntime + + +def _native_response(result: object) -> object: + """The value Python's own reader would return for `result` once it is cached. + + Python stores a model as its JSON text and `json.loads` a string response on read, so the + native store receives the decoded value and writes the envelope shape Python reads. + """ + if isinstance(result, BaseModel): + return json.loads(result.model_dump_json()) + if isinstance(result, str): + try: + return json.loads(result) + except ValueError: + return result + return result + def print_verbose(print_statement): try: @@ -55,6 +75,8 @@ class CacheMode(str, Enum): #### LiteLLM.Completion / Embedding Cache #### class Cache: + _native_cache: "ResponseCacheRuntime | None" = None + def __init__( self, type: LiteLLMCacheType | None = LiteLLMCacheType.LOCAL, @@ -292,6 +314,12 @@ class Cache: if self.namespace is not None and isinstance(self.cache, RedisCache): self.cache.namespace = self.namespace + from litellm.rust_bridge.response_cache import resolve_response_cache + + # The Rust catalog picks the store per backend. When it selects Rust, the storage calls + # below go to the native runtime and the Python backend stays only for its direct API. + self._native_cache = resolve_response_cache(self) + # Params whose values carry prompt content. Excluded from semantic-cache # scope keys so differently worded prompts share a bucket and match via # vector similarity rather than being split into per-wording buckets. @@ -570,6 +598,13 @@ class Cache: if "semantic-similarity" in cache_lookup_metadata: original_metadata["semantic-similarity"] = cache_lookup_metadata["semantic-similarity"] + @staticmethod + def _stamp_semantic_similarity(kwargs: Mapping[str, object], similarity: float | None) -> None: + """Write a native semantic lookup's similarity where the Python backends put it.""" + metadata: Final = kwargs.get("metadata") + if similarity is not None and isinstance(metadata, dict): + metadata["semantic-similarity"] = similarity + def get_cache(self, dynamic_cache_object: BaseCache | None = None, **kwargs): """ Retrieves the cached result for the given arguments. @@ -588,6 +623,15 @@ class Cache: cache_key = kwargs["cache_key"] else: cache_key = self.get_cache_key(**kwargs) + if cache_key is not None and self._native_cache is not None: + request = self._native_cache.request(self, MappingProxyType({**kwargs, "cache_key": cache_key})) + if request is None: + return None + if not self._is_semantic_cache(): + return self._native_cache.lookup(request) + response, similarity = self._native_cache.lookup_semantic(request) + self._stamp_semantic_similarity(kwargs, similarity) + return response if cache_key is not None: cache_control_args: Final[DynamicCacheControl] = kwargs.get("cache", {}) max_age = cache_control_args.get("s-maxage") or cache_control_args.get("s-max-age") or float("inf") @@ -620,6 +664,15 @@ class Cache: cache_key = kwargs["cache_key"] else: cache_key = self.get_cache_key(**kwargs) + if cache_key is not None and self._native_cache is not None: + request = self._native_cache.request(self, MappingProxyType({**kwargs, "cache_key": cache_key})) + if request is None: + return None + if not self._is_semantic_cache(): + return await self._native_cache.async_lookup(request) + response, similarity = await self._native_cache.async_lookup_semantic(request) + self._stamp_semantic_similarity(kwargs, similarity) + return response if cache_key is not None: cache_control_args: Final = kwargs.get("cache", {}) max_age: Final = cache_control_args.get("s-max-age", cache_control_args.get("s-maxage", float("inf"))) @@ -676,6 +729,11 @@ class Cache: try: if self.should_use_cache(**kwargs) is not True: return + if self._native_cache is not None: + request = self._native_request(kwargs) + if request is not None: + self._native_cache.store(request, _native_response(result)) + return cache_key, cached_data, kwargs = self._add_cache_logic(result=result, **kwargs) self.cache.set_cache(cache_key, cached_data, **kwargs) except Exception as e: @@ -695,6 +753,11 @@ class Cache: try: if self.should_use_cache(**kwargs) is not True: return + if self._native_cache is not None: + request = self._native_request(kwargs) + if request is not None: + await self._native_cache.async_store(request, _native_response(result)) + return if self.type == "redis" and self.redis_flush_size is not None: # high traffic - fill in results in memory and then flush await self.batch_cache_write(result, **kwargs) @@ -879,13 +942,35 @@ class Cache: cache_key, cached_data, kwargs = self.add_embedding_response_to_cache(result, kwargs["input"], kwargs) cache_list.append((cache_key, cached_data)) - if dynamic_cache_object is not None: + if self._native_cache is not None: + entries: Final = tuple( + (request, cached_data["response"]) + for cache_key, cached_data in cache_list + if (request := self._native_request(MappingProxyType({**kwargs, "cache_key": cache_key}))) + is not None + ) + await self._native_cache.async_store_batch( + tuple(request for request, _ in entries), + tuple(response for _, response in entries), + ) + elif dynamic_cache_object is not None: await dynamic_cache_object.async_set_cache_pipeline(cache_list=cache_list, **kwargs) else: await self.cache.async_set_cache_pipeline(cache_list=cache_list, **kwargs) except Exception as e: self._log_add_cache_failure(e) + def _native_request(self, kwargs: Mapping[str, object]) -> "NativeCacheRequest | None": + if self._native_cache is None: + return None + cache_key: Final = kwargs.get("cache_key") + return self._native_cache.request( + self, + kwargs + if isinstance(cache_key, str) + else MappingProxyType({**kwargs, "cache_key": self.get_cache_key(**kwargs)}), + ) + def should_use_cache(self, **kwargs): """ Returns true if we should use the cache for LLM API calls diff --git a/litellm/constants.py b/litellm/constants.py index 1012ca831f2..e5b662bd515 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -238,6 +238,9 @@ LOGS_GUARDRAIL_INFORMATION_MARKER: Final = "_litellm_logs_guardrail_information" # llm_provider stamped on proxy-side rate limit errors when the model resolves to no deployment PROXY_LLM_PROVIDER_FALLBACK: Final = "litellm_proxy" +# litellm_params flag on failure logs for requests the proxy rejected before routing to a deployment +PROXY_REJECTED_BEFORE_ROUTING_KEY: Final = "proxy_rejected_before_routing" + # Generic fallback for unknown models DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int( os.getenv("DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET", 128) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index ae54b91c5a0..d66d80a564b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1532,6 +1532,7 @@ def completion_cost( size=size, optional_params=optional_params, call_type=call_type, + model_info=_deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id), ) elif call_type in _VIDEO_CALL_TYPES: ### VIDEO GENERATION COST CALCULATION ### @@ -2011,13 +2012,9 @@ def _deployment_model_info( ) -> ModelInfo | None: if not custom_pricing: return None - registered_deployment_info: Final = ( - _cost_map_model_info(router_model_id, None) - if router_model_id is not None and router_model_id in litellm.model_cost - else None - ) + registered_deployment_info: Final = _raw_cost_map_entry(router_model_id) if router_model_id is not None else None if registered_deployment_info is not None: - return registered_deployment_info + return cast(ModelInfo, registered_deployment_info) # cast-ok: router registers deployment prices under its id if litellm_logging_obj is None: return None litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None) @@ -2085,8 +2082,7 @@ def pricing_entry_for_cost_calc( deployment_entry: Final = _deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id) deployment_key: Final = router_model_id or model if deployment_entry is not None and deployment_key is not None: - registered_entry: Final = _raw_cost_map_entry(router_model_id) if router_model_id is not None else None - return deployment_key, registered_entry or deployment_entry + return deployment_key, deployment_entry selected_model: Final = _select_model_name_for_cost_calc( model=model, completion_response=completion_response, @@ -2346,6 +2342,7 @@ def default_image_cost_calculator( n: int | None = 1, # Default to 1 image size: str | None = "1024-x-1024", # OpenAI default optional_params: dict | None = None, + model_info: ModelInfo | None = None, ) -> float: """ Default image cost calculator for image generation @@ -2356,6 +2353,7 @@ def default_image_cost_calculator( quality (Optional[str]): Image quality setting n (Optional[int]): Number of images generated size (Optional[str]): Image size (e.g. "1024x1024" or "1024-x-1024") + model_info (Optional[ModelInfo]): The deployment's own prices, consulted before the cost map Returns: float: Cost in USD for the image generation @@ -2386,9 +2384,7 @@ def default_image_cost_calculator( model_without_provider: Final = f"{size_str}/{model.split('/')[-1]}" model_with_quality_without_provider = f"{quality}/{model_without_provider}" if quality else model_without_provider - # Try model with quality first, fall back to base model name - cost_info: dict | None = None - models_to_check: Final[list[str | None]] = [ + models_to_check: Final = ( model_name_with_quality, base_model_name, model_name_with_v2_quality, @@ -2396,22 +2392,33 @@ def default_image_cost_calculator( model_without_provider, model, model_name_without_custom_llm_provider, - ] - for _model in models_to_check: - if _model is not None and _model in litellm.model_cost: - cost_info = litellm.model_cost[_model] - break - if cost_info is None: + ) + matched_model: Final = next( + (_model for _model in models_to_check if _model is not None and _model in litellm.model_cost), None + ) + if matched_model is None and model_info is None: raise Exception(f"Model not found in cost map. Tried checking {models_to_check}") - # Priority 1: Use per-image pricing if available (for gpt-image-1 and similar models) - if "input_cost_per_image" in cost_info and cost_info["input_cost_per_image"] is not None: - return cost_info["input_cost_per_image"] * n - # Priority 2: Fall back to per-pixel pricing for backward compatibility - elif "input_cost_per_pixel" in cost_info and cost_info["input_cost_per_pixel"] is not None: - return cost_info["input_cost_per_pixel"] * height * width * n - else: + shared_cost_info: Final = litellm.model_cost[matched_model] if matched_model is not None else None + price_tables: Final = tuple(table for table in (model_info, shared_cost_info) if table is not None) + image_count: Final = n if n is not None else 1 + unit_counts: Final = ( + ("input_cost_per_image", image_count), + ("output_cost_per_image", image_count), + ("input_cost_per_pixel", height * width * image_count), + ) + cost: Final = next( + ( + price * units + for price_table in price_tables + for cost_key, units in unit_counts + if (price := price_table.get(cost_key)) is not None + ), + None, + ) + if cost is None: raise Exception(f"No pricing information found for model {model}. Tried checking {models_to_check}") + return cost def default_video_cost_calculator( diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index 49434befd4e..1ccae8de35f 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -7,11 +7,11 @@ import base64 import hashlib import json import os -from collections.abc import Awaitable, Callable, Generator, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Sequence from contextlib import AbstractAsyncContextManager from functools import partial from types import MappingProxyType -from typing import Any, Final, TypeAlias, TypeVar +from typing import Final, TypeAlias, TypeVar, cast import anyio import httpx2 @@ -121,14 +121,14 @@ def _strip_header_whitespace(headers: dict[str, str]) -> dict[str, str]: } -def _first_non_cancelled_cause(exc: BaseException) -> BaseException | None: +def _first_non_cancelled_cause(exc: BaseException, cleanup_errors: tuple[Exception, ...] = ()) -> BaseException | None: queue: Final[list[BaseException]] = [exc] while queue: current = queue.pop(0) nested = getattr(current, "exceptions", None) if nested: queue.extend(nested) - elif not isinstance(current, asyncio.CancelledError): + elif not isinstance(current, asyncio.CancelledError) and not any(current is error for error in cleanup_errors): return current return None @@ -159,7 +159,59 @@ _ListPage = TypeVar("_ListPage", bound=PaginatedResult) _ListItem = TypeVar("_ListItem") +async def _run_bounded_cleanup(operation: Callable[[], Awaitable[TSessionResult]], deadline: float) -> TSessionResult: + async def run() -> TSessionResult: + with anyio.fail_after(max(0, deadline - anyio.current_time()), shield=True): + return await operation() + + # A cancelled asyncio.gather repeatedly forwards Task.cancel, bypassing AnyIO shields. + # Isolate only cleanup, and drain it before propagating the caller's cancellation. + task: Final = asyncio.create_task(run()) + interrupted: asyncio.CancelledError | None = None + with anyio.CancelScope(shield=True): + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as exc: + interrupted = exc + except Exception: + break + if interrupted is not None: + if not task.cancelled(): + task.exception() + raise interrupted + return task.result() + + +class _MCPResponseStream(httpx2.AsyncByteStream): + def __init__(self, stream: httpx2.AsyncByteStream, record_error: Callable[[Exception], None]) -> None: + self._stream: Final = stream + self._record_error: Final = record_error + + async def __aiter__(self) -> AsyncIterator[bytes]: + try: + async for chunk in self._stream: + yield chunk + except Exception as error: + self._record_error(error) + raise + + async def aclose(self) -> None: + try: + await self._stream.aclose() + except Exception as error: + self._record_error(error) + raise + + class _MCPHTTPClient(httpx2.AsyncClient): + cleanup_scope: anyio.CancelScope | None = None + cleanup_errors: tuple[Exception, ...] = () + + def _record_cleanup_error(self, error: Exception) -> None: + if self.cleanup_scope is not None and self.cleanup_scope.shield: + self.cleanup_errors += (error,) + async def send( self, request: httpx2.Request, @@ -168,11 +220,29 @@ class _MCPHTTPClient(httpx2.AsyncClient): auth: AuthTypes | UseClientDefault | None = httpx2.USE_CLIENT_DEFAULT, follow_redirects: bool | UseClientDefault = httpx2.USE_CLIENT_DEFAULT, ) -> httpx2.Response: - response: Final = await super().send(request, stream=stream, auth=auth, follow_redirects=follow_redirects) - if request.method == "POST" and response.is_error and response.status_code != 404: - await response.aclose() - response.raise_for_status() - return response + if request.method == "DELETE" and self.cleanup_scope is not None: + + async def terminate() -> httpx2.Response: + termination: Final = await super(_MCPHTTPClient, self).send( + request, stream=stream, auth=auth, follow_redirects=follow_redirects + ) + await termination.aread() + return termination + + return await _run_bounded_cleanup(terminate, self.cleanup_scope.deadline) + try: + response: Final = await super().send(request, stream=stream, auth=auth, follow_redirects=follow_redirects) + if request.method == "POST" and response.is_error and response.status_code != 404: + await response.aclose() + response.raise_for_status() + if stream: + response.stream = _MCPResponseStream( + cast(httpx2.AsyncByteStream, response.stream), self._record_cleanup_error + ) + return response + except Exception as error: + self._record_cleanup_error(error) + raise class MCPSigV4Auth(httpx2.Auth): @@ -458,6 +528,7 @@ class MCPClient: self, transport_ctx: _TransportContext, operation: Callable[[ClientSession], Awaitable[TSessionResult]], + http_client: httpx2.AsyncClient | None = None, ) -> TSessionResult: """ Execute an operation within a transport and session context. @@ -466,69 +537,97 @@ class MCPClient: so that upstream MCP servers can request LLM inference (sampling), user input (elicitation), or send log messages. """ - transport: Final = await transport_ctx.__aenter__() in_flight_error: BaseException | None = None - try: - read_stream: Final = transport[0] - write_stream: Final = transport[1] - stream_error: Final[asyncio.Future[Exception]] = asyncio.get_running_loop().create_future() - - async def receive_message( - message: ServerNotification | Exception, - ) -> None: - if not isinstance(message, (ValueError, httpx2.HTTPError, OSError)): - return - if not stream_error.done(): - stream_error.set_result(message) - # The SDK closes pending requests when its message handler raises. - raise RuntimeError("MCP response stream failed") - - # Build session kwargs with optional callbacks - session_kwargs: Final[dict[str, Any]] = {} - if self._sampling_callback is not None: - session_kwargs["sampling_callback"] = self._sampling_callback - if self._elicitation_callback is not None: - session_kwargs["elicitation_callback"] = self._elicitation_callback - if self._logging_callback is not None: - session_kwargs["logging_callback"] = self._logging_callback - # The SDK drops a response stream that ends without a JSON-RPC reply, so nothing else - # ever fails the request. - session_ctx: Final = ClientSession( - read_stream, - write_stream, - read_timeout_seconds=self.timeout, - message_handler=receive_message, - **session_kwargs, - ) - session: Final = await session_ctx.__aenter__() + with anyio.CancelScope() as cleanup_scope: + if isinstance(http_client, _MCPHTTPClient): + http_client.cleanup_scope = cleanup_scope try: - init_result: Final = await session.initialize() - self._last_initialize_instructions = None - if init_result is not None: - ins: Final = getattr(init_result, "instructions", None) - if isinstance(ins, str) and ins.strip(): - self._last_initialize_instructions = ins.strip() - return await operation(session) - except MCPError: - if stream_error.done(): - raise stream_error.result() - raise - finally: + transport: Final = await transport_ctx.__aenter__() try: - await session_ctx.__aexit__(None, None, None) + read_stream: Final = transport[0] + write_stream: Final = transport[1] + stream_error: Final[asyncio.Future[Exception]] = asyncio.get_running_loop().create_future() + + async def receive_message( + message: ServerNotification | Exception, + ) -> None: + if not isinstance(message, (ValueError, httpx2.HTTPError, OSError)): + return + if not stream_error.done(): + stream_error.set_result(message) + # The SDK closes pending requests when its message handler raises. + raise RuntimeError("MCP response stream failed") + + session_kwargs: Final = { + name: callback + for name, callback in ( + ("sampling_callback", self._sampling_callback), + ("elicitation_callback", self._elicitation_callback), + ("logging_callback", self._logging_callback), + ) + if callback is not None + } + # The SDK drops a response stream that ends without a JSON-RPC reply, so nothing else + # ever fails the request. + session_ctx: Final = ClientSession( + read_stream, + write_stream, + read_timeout_seconds=self.timeout, + message_handler=receive_message, + **session_kwargs, + ) + session: Final = await session_ctx.__aenter__() + try: + init_result: Final = await session.initialize() + instructions: Final = getattr(init_result, "instructions", None) + self._last_initialize_instructions = ( + instructions.strip() or None if isinstance(instructions, str) else None + ) + result: Final = await operation(session) + except BaseException as operation_error: + in_flight_error = operation_error + if isinstance(operation_error, MCPError) and stream_error.done(): + raise stream_error.result() + raise + finally: + cleanup_scope.shield = True + cleanup_scope.deadline = anyio.current_time() + 5 + try: + await session_ctx.__aexit__(None, None, None) + except (Exception, asyncio.CancelledError) as e: + verbose_logger.debug("Error during session context exit: %s", e) + if in_flight_error is None and isinstance(e, asyncio.CancelledError): + raise except BaseException as e: - verbose_logger.debug("Error during session context exit: %s", e) - except BaseException as e: - in_flight_error = e - raise - finally: - try: - await transport_ctx.__aexit__(None, None, None) - except BaseException as exit_error: - verbose_logger.debug("Error during transport context exit: %s", exit_error) - root_cause: Final = _first_non_cancelled_cause(exit_error) - if root_cause is not None and isinstance(in_flight_error, asyncio.CancelledError): - raise root_cause from in_flight_error + in_flight_error = e + raise + finally: + cleanup_scope.shield = True + cleanup_scope.deadline = min(cleanup_scope.deadline, anyio.current_time() + 5) + try: + await transport_ctx.__aexit__(None, None, None) + except (Exception, asyncio.CancelledError) as exit_error: + verbose_logger.debug("Error during transport context exit: %s", exit_error) + if in_flight_error is None and isinstance(exit_error, asyncio.CancelledError): + raise + root_cause: Final = _first_non_cancelled_cause( + exit_error, http_client.cleanup_errors if isinstance(http_client, _MCPHTTPClient) else () + ) + if root_cause is not None and isinstance(in_flight_error, asyncio.CancelledError): + raise root_cause from in_flight_error + finally: + cleanup_scope.shield = False + if isinstance(http_client, _MCPHTTPClient): + http_client.cleanup_errors = () + http_client.cleanup_scope = None + await anyio.lowlevel.checkpoint_if_cancelled() + if cleanup_scope.cancel_called: + raise ( + in_flight_error + if in_flight_error is not None + else asyncio.CancelledError("MCP session cleanup timed out") + ) + return result async def run_with_session( self, @@ -542,10 +641,11 @@ class MCPClient: (call_tool / list_tools under raise_on_error), so an expected pass-through re-auth does not emit a warning per call; every other caller keeps the operator-visible warning.""" http_client: httpx2.AsyncClient | None = None + close_cancellation: asyncio.CancelledError | None = None try: self._last_initialize_instructions = None transport_ctx, http_client = self._create_transport_context() - return await self._execute_session_operation(transport_ctx, operation) + result: Final = await self._execute_session_operation(transport_ctx, operation, http_client=http_client) except Exception as e: read_timeout: Final = as_mcp_read_timeout(e) if read_timeout is not None: @@ -561,9 +661,16 @@ class MCPClient: finally: if http_client is not None: try: - await http_client.aclose() - except BaseException as e: + await _run_bounded_cleanup(http_client.aclose, anyio.current_time() + 1) + except (Exception, asyncio.CancelledError) as e: verbose_logger.debug("Error during http_client cleanup: %s", e) + if isinstance(e, asyncio.CancelledError): + close_cancellation = e + + if close_cancellation is not None: + raise close_cancellation + await anyio.lowlevel.checkpoint_if_cancelled() + return result def update_auth_value(self, mcp_auth_value: str | dict[str, str]) -> None: """ diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 5b5261fab6b..326abd5c6a3 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -574,11 +574,10 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac Useful if you want to modify the standard logging payload after the MCP tool call is made. - To change what the caller sends back to the MCP client, mutate ``response_obj`` - in place: every call site discards the returned object, because the - dispatcher unwraps it to ``mcp_tool_call_response`` (a raw content list, not - a ``CallToolResult``) which the tool-call paths cannot forward. Guardrails - that mask or reject tool output should use ``post_mcp_call`` instead. + Modify ``mcp_tool_call_response`` in place or return a replacement response + object to change what the caller sends back to the MCP client. Content rewrites + discard stale structured output and mark those results as tool errors. + Use ``post_mcp_call`` guardrails for schema-preserving structured redaction. """ return None diff --git a/litellm/integrations/langsmith.py b/litellm/integrations/langsmith.py index 352fcdf90f3..991b0411ee8 100644 --- a/litellm/integrations/langsmith.py +++ b/litellm/integrations/langsmith.py @@ -1,6 +1,7 @@ #### What this does #### # On success, logs events to Langsmith import asyncio +import json import os import random import traceback @@ -415,7 +416,7 @@ class LangsmithLogger(CustomBatchLogger): langsmith_api_key: Final = credentials["LANGSMITH_API_KEY"] langsmith_tenant_id: Final = credentials.get("LANGSMITH_TENANT_ID") url: Final = self._add_endpoint_to_url(langsmith_api_base, "runs/batch") - headers: Final = {"x-api-key": langsmith_api_key} + headers: Final = {"x-api-key": langsmith_api_key, "Content-Type": "application/json"} if langsmith_tenant_id: headers["x-tenant-id"] = langsmith_tenant_id elements_to_log: Final = [queue_object["data"] for queue_object in queue_objects] @@ -426,7 +427,7 @@ class LangsmithLogger(CustomBatchLogger): verbose_logger.debug("[LANGSMITH MOCK] Mock mode enabled - API calls will be intercepted") response: Final = await self.async_httpx_client.post( url=url, - json={"post": elements_to_log}, + content=json.dumps({"post": elements_to_log}, default=str, allow_nan=False), headers=headers, ) response.raise_for_status() diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index feaeaec27a3..749f0ce4fcb 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -26,6 +26,7 @@ from litellm.integrations.otel.model.baggage import promoted_metadata from litellm.integrations.otel.model.db_endpoint import db_span_attributes from litellm.integrations.otel.model.metadata import flatten_metadata from litellm.integrations.otel.model.semconv import Metric +from litellm.integrations.otel.plumbing.otlp_tls import resolve_otlp_http_tls from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.secret_redaction import redact_string @@ -99,6 +100,7 @@ _MAX_DYNAMIC_TRACER_PROVIDERS: Final = 256 # Dedicated so a slow exporter shutdown cannot starve the shared logging executor. _PROVIDER_SHUTDOWN_EXECUTOR: Final = ThreadPoolExecutor(max_workers=4, thread_name_prefix="OtelProviderShutdown") + LITELLM_TRACER_NAME: Final = os.getenv("OTEL_TRACER_NAME", "litellm") LITELLM_METER_NAME: Final = os.getenv("LITELLM_METER_NAME", "litellm") LITELLM_LOGGER_NAME: Final = os.getenv("LITELLM_LOGGER_NAME", "litellm") @@ -3090,8 +3092,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): otel_exporter, ) normalized_endpoint = self._normalize_otel_endpoint(otel_endpoint, "traces") + tls: Final = resolve_otlp_http_tls("TRACES") return BatchSpanProcessor( - OTLPSpanExporterHTTP(endpoint=normalized_endpoint, headers=_split_otel_headers), + OTLPSpanExporterHTTP( + endpoint=normalized_endpoint, + headers=_split_otel_headers, + certificate_file=tls.certificate_file, + session=tls.session, + ), ) elif otel_exporter == "otlp_grpc" or otel_exporter == "grpc": try: @@ -3172,7 +3180,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): self.OTEL_EXPORTER, normalized_endpoint, ) - return OTLPLogExporter(endpoint=normalized_endpoint, headers=_split_otel_headers) + tls: Final = resolve_otlp_http_tls("LOGS") + return OTLPLogExporter( + endpoint=normalized_endpoint, + headers=_split_otel_headers, + certificate_file=tls.certificate_file, + session=tls.session, + ) elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc": try: from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( @@ -3235,9 +3249,12 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): OTLPMetricExporter, ) + tls: Final = resolve_otlp_http_tls("METRICS") exporter = OTLPMetricExporter( endpoint=normalized_endpoint, headers=_split_otel_headers, + certificate_file=tls.certificate_file, + session=tls.session, ) return PeriodicExportingMetricReader(exporter, export_interval_millis=5000) diff --git a/litellm/integrations/otel/plumbing/otlp_json.py b/litellm/integrations/otel/plumbing/otlp_json.py index b4b659f1e01..bc6d7d435b0 100644 --- a/litellm/integrations/otel/plumbing/otlp_json.py +++ b/litellm/integrations/otel/plumbing/otlp_json.py @@ -10,6 +10,7 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import Final, TypeAlias +import requests from google.protobuf.json_format import MessageToDict from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter @@ -62,8 +63,14 @@ def encode_spans_json(spans: Sequence[ReadableSpan]) -> bytes: class OTLPJsonSpanExporter(OTLPSpanExporter): - def __init__(self, endpoint: str | None, headers: dict[str, str]) -> None: # mutable-ok: SDK __init__ takes Dict - super().__init__(endpoint=endpoint, headers=headers) + def __init__( + self, + endpoint: str | None, + headers: dict[str, str], # mutable-ok: SDK __init__ takes Dict + certificate_file: str | None = None, + session: "requests.Session | None" = None, + ) -> None: + super().__init__(endpoint=endpoint, headers=headers, certificate_file=certificate_file, session=session) self._session.headers["Content-Type"] = JSON_CONTENT_TYPE def _serialize_spans(self, spans: Sequence[ReadableSpan]) -> bytes: diff --git a/litellm/integrations/otel/plumbing/otlp_tls.py b/litellm/integrations/otel/plumbing/otlp_tls.py new file mode 100644 index 00000000000..9de97e0c77a --- /dev/null +++ b/litellm/integrations/otel/plumbing/otlp_tls.py @@ -0,0 +1,41 @@ +import os +from dataclasses import dataclass +from typing import Final, Literal + +import requests +from requests.adapters import HTTPAdapter + + +@dataclass(frozen=True, slots=True) +class OtlpHttpTls: + certificate_file: str | None + session: requests.Session | None + + +class _NoVerifyAdapter(HTTPAdapter): + def cert_verify( + self, + conn: object, + url: str, + verify: bool | str, + cert: str | tuple[str, str] | None, + ) -> None: + super().cert_verify( # pyright: ignore[reportUnknownMemberType] # requests stubs omit HTTPAdapter.cert_verify + conn, url, False, cert + ) + + +def resolve_otlp_http_tls(signal: Literal["TRACES", "METRICS", "LOGS"]) -> OtlpHttpTls: + if os.getenv(f"OTEL_EXPORTER_OTLP_{signal}_CERTIFICATE") or os.getenv("OTEL_EXPORTER_OTLP_CERTIFICATE"): + return OtlpHttpTls(certificate_file=None, session=None) + + from litellm.llms.custom_httpx.http_handler import get_ssl_verify + + verify: Final = get_ssl_verify() + if verify is False: + session: Final = requests.Session() + session.mount("https://", _NoVerifyAdapter()) + return OtlpHttpTls(certificate_file=None, session=session) + if isinstance(verify, str): + return OtlpHttpTls(certificate_file=verify, session=None) + return OtlpHttpTls(certificate_file=None, session=None) diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 2c4375ce5f7..d52736a1303 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -58,6 +58,7 @@ from litellm.integrations.otel.plumbing.context import ( request_destinations, suppressed_backends, ) +from litellm.integrations.otel.plumbing.otlp_tls import resolve_otlp_http_tls if TYPE_CHECKING: from opentelemetry.metrics import Meter @@ -193,18 +194,24 @@ def _exporter_from_spec(spec: ExporterSpec) -> SpanExporter: if kind in _OTLP_HTTP_JSON_KINDS: from litellm.integrations.otel.plumbing.otlp_json import OTLPJsonSpanExporter + tls: Final = resolve_otlp_http_tls("TRACES") return OTLPJsonSpanExporter( endpoint=spec.traces_endpoint or _otlp_traces_endpoint(spec.endpoint), headers=parse_headers(spec.headers), + certificate_file=tls.certificate_file, + session=tls.session, ) if kind in _OTLP_HTTP_KINDS: from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( OTLPSpanExporter as HTTPExporter, ) + http_tls: Final = resolve_otlp_http_tls("TRACES") return HTTPExporter( endpoint=spec.traces_endpoint or _otlp_traces_endpoint(spec.endpoint), headers=parse_headers(spec.headers), + certificate_file=http_tls.certificate_file, + session=http_tls.session, ) if kind in _OTLP_GRPC_KINDS: from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( @@ -902,9 +909,12 @@ def build_metric_reader(config: OpenTelemetryV2Config) -> "MetricReader": OTLPMetricExporter as HTTPMetricExporter, ) + tls: Final = resolve_otlp_http_tls("METRICS") exporter: Any = HTTPMetricExporter( endpoint=_otlp_metrics_endpoint(config.endpoint), headers=parse_headers(config.headers), + certificate_file=tls.certificate_file, + session=tls.session, ) elif kind in ("otlp_grpc", "grpc"): try: @@ -962,9 +972,12 @@ def build_log_exporter(config: OpenTelemetryV2Config) -> LogExporter: OTLPLogExporter as HTTPLogExporter, ) + tls: Final = resolve_otlp_http_tls("LOGS") return HTTPLogExporter( endpoint=_otlp_logs_endpoint(config.endpoint), headers=parse_headers(config.headers), + certificate_file=tls.certificate_file, + session=tls.session, ) if kind in ("otlp_grpc", "grpc"): try: diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 28ac9f5cdae..d62a6c3427a 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -18,7 +18,7 @@ from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import print_verbose, verbose_logger -from litellm.constants import PROXY_LLM_PROVIDER_FALLBACK +from litellm.constants import PROXY_LLM_PROVIDER_FALLBACK, PROXY_REJECTED_BEFORE_ROUTING_KEY from litellm.exceptions import ( validate_rate_limit_category, validate_rate_limit_type, @@ -214,17 +214,22 @@ def _get_proxy_llm_router() -> Router | None: return llm_router -def _bounded_requested_model_label(requested_model: str | None, router_originated: bool = False) -> str | None: +def _bounded_requested_model_label(requested_model: object, router_originated: bool = False) -> str | None: """ Bound ``requested_model`` label cardinality: names the router recognizes (model names, deployment ids, aliases, routing groups, team public model names) or matches via a global or team wildcard/pattern route keep their own label value; any other client-supplied string collapses into the - single ``other`` bucket. With no proxy router to vouch for the string, - client-supplied values collapse to ``other`` while ``router_originated`` - values (emitted by an SDK ``Router``'s own deployment failure and - fallback events, where the proxy router never exists) pass through. + single ``other`` bucket, as does any non-string request ``model`` value. + With no proxy router to vouch for the string, client-supplied values + collapse to ``other`` while ``router_originated`` values (emitted by an + SDK ``Router``'s own deployment failure and fallback events, where the + proxy router never exists) pass through. """ + if requested_model is None: + return None + if not isinstance(requested_model, str): + return UNRECOGNIZED_REQUESTED_MODEL_LABEL if not requested_model: return requested_model llm_router: Final = _get_proxy_llm_router() @@ -2832,7 +2837,7 @@ class PrometheusLogger(CustomLogger): # On LiteLLM-side rejects (no deployment picked), route request_kwargs["model"] # into requested_model and leave deployment-scoped labels empty. - deployment_selected: Final = bool(model_id) + deployment_selected: Final = bool(model_id) and not _litellm_params.get(PROXY_REJECTED_BEFORE_ROUTING_KEY) if deployment_selected: label_litellm_model_name = litellm_model_name label_model_id = model_id diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 6a4c67c7db1..54578323fa4 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -10,6 +10,8 @@ import asyncio import math import uuid from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, cast from typing_extensions import Never, ReadOnly @@ -196,6 +198,46 @@ class _AcompletionNamedParams(TypedDict, total=False): _NO_ACREATE_NAMED: Final[_AcreateNamedParams] = {} _NO_ASEARCH_NAMED: Final[_AsearchNamedParams] = {} + + +def _as_str_mapping(value: object) -> Mapping[str, object] | None: + return value if isinstance(value, Mapping) else None # pyright: ignore[reportUnknownVariableType] # str-keyed request metadata is not narrowable from object + + +@dataclass(frozen=True, slots=True) +class _ParentRequestCorrelation: + """Correlation ids of the LLM request that triggered an intercepted search, so the search's + own spend log row and traces land under the same session/trace instead of a fresh one.""" + + session_id: str | None + trace_id: str | None + parent_request_id: str | None + parent_otel_span: object | None + + def as_search_metadata(self) -> Mapping[str, object]: + return MappingProxyType( + { + key: value + for key, value in ( + ("session_id", self.session_id), + ("trace_id", self.trace_id), + ("parent_request_id", self.parent_request_id), + ("litellm_parent_otel_span", self.parent_otel_span), + ) + if value is not None + } + ) + + def as_search_kwargs(self) -> Mapping[str, str]: + return MappingProxyType( + { + key: value + for key, value in (("litellm_session_id", self.session_id), ("litellm_trace_id", self.trace_id)) + if value is not None + } + ) + + _NO_ACOMPLETION_NAMED: Final[_AcompletionNamedParams] = {} @@ -1527,15 +1569,17 @@ class WebSearchInterceptionLogger(CustomLogger): "WebSearchInterception: Executing search for '%s' using provider '%s'", query, search_provider ) user_api_key_auth: Final = self._get_user_api_key_auth_from_kwargs(kwargs) + parent_correlation: Final = self._get_parent_request_correlation(kwargs, user_api_key_auth) search_metadata: Final = ( None if user_api_key_auth is None else self._build_search_request_metadata( user_api_key_auth=user_api_key_auth, search_tool_name=search_tool_name, + parent_correlation=parent_correlation, ) ) - search_kwargs: Final = { + configured_search_kwargs: Final = { key: value for key, value in search_litellm_params.items() if key != "search_provider" and value is not None @@ -1549,8 +1593,11 @@ class WebSearchInterceptionLogger(CustomLogger): if rich_queries: query_arg = rich_queries rich_objective = rich.get("objective") - if rich_objective and "objective" not in search_kwargs: - search_kwargs["objective"] = rich_objective + if rich_objective and "objective" not in configured_search_kwargs: + configured_search_kwargs["objective"] = rich_objective + search_kwargs: Final = MappingProxyType( + {**configured_search_kwargs, **parent_correlation.as_search_kwargs()} + ) result: Final = ( await litellm.asearch( query=query_arg, search_provider=search_provider, **_NO_ASEARCH_NAMED, **search_kwargs @@ -1624,6 +1671,7 @@ class WebSearchInterceptionLogger(CustomLogger): def _build_search_request_metadata( user_api_key_auth: "UserAPIKeyAuth", search_tool_name: str | None, + parent_correlation: _ParentRequestCorrelation, ) -> Mapping[str, object]: """ Spend-tracking metadata for the intercepted search, so its provider cost is logged @@ -1637,11 +1685,50 @@ class WebSearchInterceptionLogger(CustomLogger): ) return { # mutable-ok: litellm's metadata channel is a plain dict its logging path reads and enriches **user_api_key_metadata, + **parent_correlation.as_search_metadata(), "model_group": search_tool_name, "user_api_key": user_api_key_auth.api_key, "user_api_key_auth": user_api_key_auth, } + @staticmethod + def _get_parent_request_correlation( + kwargs: Mapping[str, object] | None, + user_api_key_auth: "UserAPIKeyAuth | None", + ) -> _ParentRequestCorrelation: + """Read the originating request's ids from the hook kwargs, which are either the raw call + kwargs (metadata/litellm_metadata at top level) or a logging payload (under litellm_params).""" + if not kwargs: + return _ParentRequestCorrelation(None, None, None, None) + litellm_params: Final = _as_str_mapping(kwargs.get("litellm_params")) + scopes: Final[tuple[Mapping[str, object], ...]] = ( + (kwargs,) if litellm_params is None else (kwargs, litellm_params) + ) + metadatas: Final[tuple[Mapping[str, object], ...]] = tuple( + metadata + for scope in scopes + for metadata_key in ("metadata", "litellm_metadata") + if (metadata := _as_str_mapping(scope.get(metadata_key))) is not None + ) + + def first_str(scope_key: str | None, metadata_key: str | None) -> str | None: + candidates: Final[tuple[object, ...]] = ( + *(scope.get(scope_key) for scope in scopes if scope_key is not None), + *(metadata.get(metadata_key) for metadata in metadatas if metadata_key is not None), + ) + return next((value for value in candidates if isinstance(value, str) and value), None) + + parent_otel_span: Final[object | None] = next( + (span for metadata in metadatas if (span := metadata.get("litellm_parent_otel_span")) is not None), + None if user_api_key_auth is None else user_api_key_auth.parent_otel_span, + ) + return _ParentRequestCorrelation( + session_id=first_str("litellm_session_id", "session_id"), + trace_id=first_str("litellm_trace_id", "trace_id"), + parent_request_id=first_str("litellm_call_id", None), + parent_otel_span=parent_otel_span, + ) + @staticmethod def _selected_search_tool_name(search_tool: Mapping[str, object] | None) -> str | None: if search_tool is None: diff --git a/litellm/litellm_core_utils/bug_report.py b/litellm/litellm_core_utils/bug_report.py index 4a8f4bc65ad..25fddd6a10e 100644 --- a/litellm/litellm_core_utils/bug_report.py +++ b/litellm/litellm_core_utils/bug_report.py @@ -23,16 +23,22 @@ Surface = Literal["sdk", "proxy"] @dataclass(frozen=True, slots=True) -class BugReport: +class EnvironmentReport: surface: Surface - exception_type: str - litellm_frames: tuple[str, ...] litellm_version: str python_version: str + deployment: str | None + config_lines: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class BugReport: + environment: EnvironmentReport + exception_type: str + litellm_frames: tuple[str, ...] call_type: str | None custom_llm_provider: str | None stream: bool | None - config_lines: tuple[str, ...] def bug_report_enabled() -> bool: @@ -69,6 +75,22 @@ def allowlisted(value: object, allowed: frozenset[str]) -> str | None: return value if isinstance(value, str) and value in allowed else None +def _deployment(surface: Surface) -> str | None: + if surface == "sdk": + return "pip / Python SDK" + return "Docker" if os.path.exists("/.dockerenv") else None + + +def build_environment_report(*, surface: Surface, config_lines: tuple[str, ...] = ()) -> EnvironmentReport: + return EnvironmentReport( + surface=surface, + litellm_version=litellm_version, + python_version=platform.python_version(), + deployment=_deployment(surface), + config_lines=config_lines, + ) + + def build_bug_report( exc: BaseException, *, @@ -79,20 +101,17 @@ def build_bug_report( config_lines: tuple[str, ...] = (), ) -> BugReport: return BugReport( - surface=surface, + environment=build_environment_report(surface=surface, config_lines=config_lines), exception_type=type(exc).__name__, litellm_frames=_get_litellm_frames(exc), - litellm_version=litellm_version, - python_version=platform.python_version(), call_type=call_type, custom_llm_provider=allowlisted(custom_llm_provider, KNOWN_PROVIDERS), stream=stream if isinstance(stream, bool) else None, - config_lines=config_lines, ) def _domain(report: BugReport) -> str: - if report.surface == "sdk": + if report.environment.surface == "sdk": return "Python SDK: the litellm package itself" if report.custom_llm_provider is not None: return "LLM translation: a specific provider's request or response" @@ -119,11 +138,11 @@ def _description(report: BugReport, frames: tuple[str, ...], config_lines: tuple "```\n\n```\n\n" f"Exception: `{report.exception_type}`\n\n" f"{frame_block}" - f"Surface: {report.surface}\n" + f"Surface: {report.environment.surface}\n" f"Endpoint / call: {report.call_type or 'unknown'}\n" f"Provider: {report.custom_llm_provider or 'unknown'}\n" - f"LiteLLM: {report.litellm_version}\n" - f"Python: {report.python_version}\n" + f"LiteLLM: {report.environment.litellm_version}\n" + f"Python: {report.environment.python_version}\n" f"{stream_line}" f"{config_block}" ) @@ -131,17 +150,13 @@ def _description(report: BugReport, frames: tuple[str, ...], config_lines: tuple def _issue_url(report: BugReport, frames: tuple[str, ...], config_lines: tuple[str, ...]) -> str: deployment: Final[tuple[tuple[str, str], ...]] = ( - (("deployment", "pip / Python SDK"),) - if report.surface == "sdk" - else (("deployment", "Docker"),) - if os.path.exists("/.dockerenv") - else () + () if report.environment.deployment is None else (("deployment", report.environment.deployment),) ) fields: Final = ( ("template", "bug_report.yml"), ("labels", "bug"), ("title", _title(report, frames)), - ("version", report.litellm_version), + ("version", report.environment.litellm_version), ("domain", _domain(report)), ("description", _description(report, frames, config_lines)), ) + deployment @@ -150,7 +165,7 @@ def _issue_url(report: BugReport, frames: tuple[str, ...], config_lines: tuple[s def bug_report_issue_url(report: BugReport) -> str: frames: Final = report.litellm_frames - config_lines: Final = report.config_lines + config_lines: Final = report.environment.config_lines candidates: Final = ( *((frames, config_lines[:count]) for count in range(len(config_lines), -1, -1)), *((frames[index:], ()) for index in range(1, len(frames) + 1)), diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 5bcde688521..d7fbe9f7e09 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -3,7 +3,7 @@ import copy import logging import re -from collections.abc import Iterable, Mapping +from collections.abc import Collection, Iterable, Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Protocol @@ -709,10 +709,11 @@ def filter_internal_params(data: dict, additional_internal_params: set | None = def redact_nested_match_and_regex_keys( payload: dict | list[Any] | str | None, + keys: Collection[str] = ("match", "regex"), ) -> dict | list[Any] | str | None: """ - Deep-copy `payload` and replace every `match` / `regex` string field with - "[REDACTED]" anywhere in nested dict/list structures. + Deep-copy `payload` and replace every configured string field with "[REDACTED]" + anywhere in nested dict/list structures. Used for guardrail spend/compliance logging so raw spans are not persisted. """ @@ -734,10 +735,9 @@ def redact_nested_match_and_regex_keys( continue seen.add(node_id) if isinstance(node, dict): - if "match" in node: - node["match"] = "[REDACTED]" - if "regex" in node: - node["regex"] = "[REDACTED]" + for key in keys: + if key in node: + node[key] = "[REDACTED]" stack.extend(node.values()) elif isinstance(node, list): stack.extend(node) diff --git a/litellm/litellm_core_utils/get_litellm_params.py b/litellm/litellm_core_utils/get_litellm_params.py index 36fd7fa4e61..112d038039e 100644 --- a/litellm/litellm_core_utils/get_litellm_params.py +++ b/litellm/litellm_core_utils/get_litellm_params.py @@ -4,6 +4,7 @@ from typing import Final from litellm.litellm_core_utils.core_helpers import normalize_drop_params from litellm.llms.openai.data_residency import infer_openai_data_residency +from litellm.types.router import CustomPricingLiteLLMParams AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset( { @@ -23,10 +24,7 @@ AWS_CREDENTIAL_KWARGS_KEYS: Final = frozenset( } ) -# Keys `completion()` forwards from its own kwargs into `get_litellm_params`, -# which are otherwise invisible to it because that call site passes explicit -# named arguments rather than `**kwargs`. -FORWARDED_KWARGS_KEYS: Final = AWS_CREDENTIAL_KWARGS_KEYS +PROVIDER_AFFINITY_HEADER_KWARG_KEY: Final = "provider_affinity_header" # Pre-define optional kwargs keys as frozenset for O(1) lookups # These are extracted from kwargs only if present, avoiding unnecessary .get() calls @@ -62,9 +60,11 @@ OPTIONAL_KWARGS_KEYS: Final = ( "itpm", "otpm", "use_xai_oauth", + PROVIDER_AFFINITY_HEADER_KWARG_KEY, } ) | AWS_CREDENTIAL_KWARGS_KEYS + | frozenset(CustomPricingLiteLLMParams.model_fields) ) # Backward-compatible alias for existing imports/tests. diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 192679957b1..f622098920c 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -860,7 +860,6 @@ def _get_openai_compatible_provider_info( api_base = litellm.EdenAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place dynamic_api_key = litellm.EdenAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place elif custom_llm_provider == "fal_ai": - api_base = litellm.FalAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place dynamic_api_key = litellm.FalAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place elif custom_llm_provider == "aiml": ( diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 357ee51a44a..6e5a37a7226 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -221,7 +221,7 @@ from .initialize_dynamic_callback_params import ( from .specialty_caches.dynamic_logging_cache import DynamicLoggingCache if TYPE_CHECKING: - from mcp.types import EmbeddedResource, ImageContent, TextContent + from mcp.types import CallToolResult, EmbeddedResource, ImageContent, TextContent from litellm.integrations.otel.logger import OpenTelemetryV2 from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config @@ -1634,15 +1634,11 @@ class Logging(LiteLLMLoggingBaseClass): async def async_post_mcp_tool_call_hook( self, kwargs: dict, - response_obj: Any, + response_obj: "CallToolResult", start_time: datetime.datetime, end_time: datetime.datetime, - ): - """ - Post MCP Tool Call Hook - - Use this to modify the MCP tool call response before it is returned to the user. - """ + ) -> "CallToolResult": + """Apply ordered MCP content callbacks to the result returned to the caller.""" from litellm.types.llms.base import HiddenParams from litellm.types.mcp import MCPPostCallResponseObject @@ -1650,24 +1646,51 @@ class Logging(LiteLLMLoggingBaseClass): dynamic_success_callbacks=self.dynamic_success_callbacks, global_callbacks=litellm.success_callback, ) - post_mcp_tool_call_response_obj: Final[MCPPostCallResponseObject] = MCPPostCallResponseObject( - mcp_tool_call_response=response_obj, hidden_params=HiddenParams() - ) + hidden_params = HiddenParams() for callback in callbacks: try: if isinstance(callback, CustomLogger): - response: MCPPostCallResponseObject | None = await callback.async_post_mcp_tool_call_hook( - kwargs=kwargs, - response_obj=post_mcp_tool_call_response_obj, - start_time=start_time, - end_time=end_time, + original_content = copy.deepcopy(response_obj.content) + original_structured_content = copy.deepcopy(response_obj.structured_content) + callback_response = MCPPostCallResponseObject( + mcp_tool_call_response=copy.deepcopy(original_content), hidden_params=hidden_params ) - ###################################################################### - # if any of the callbacks modify the response, use the modified response - # current implementation returns the first modified response - ###################################################################### - if response is not None: - response_obj = self._parse_post_mcp_call_hook_response(response=response) + try: + response = await callback.async_post_mcp_tool_call_hook( + kwargs=kwargs, + response_obj=callback_response, + start_time=start_time, + end_time=end_time, + ) + hook_content = ( + self._parse_post_mcp_call_hook_response(response=response) + if response is not None + else callback_response.mcp_tool_call_response + ) + if response is not None: + hidden_params = response.hidden_params + except Exception as e: + verbose_logger.exception( + "LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging %s", e + ) + hook_content = None + structured_replacement_matches = ( + response_obj.structured_content != original_structured_content + and ( + hook_content is None + or hook_content == original_content + or response_obj.content == hook_content + ) + ) + if hook_content is not None and hook_content != original_content: + response_obj.content[:] = hook_content + if ( + response_obj.content != original_content + and response_obj.structured_content is not None + and not structured_replacement_matches + ): + response_obj.structured_content = None + response_obj.is_error = True except Exception as e: verbose_logger.exception("LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging %s", e) return response_obj diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 3845f50af79..46bf2ec2960 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -24,6 +24,7 @@ from litellm.types.utils import ( CallTypes, CompletionTokensDetailsWrapper, CostPerToken, + CustomPricingLiteLLMParams, DataResidency, ImageResponse, ModelInfo, @@ -49,6 +50,15 @@ _IMAGE_RESPONSE_CALL_TYPES: Final = frozenset( # Pre-resolved DataResidency enum values for fast membership checks _VALID_DATA_RESIDENCIES: Final = frozenset(r.value for r in DataResidency) +_DEPLOYMENT_PRICING_KEYS: Final[frozenset[str]] = frozenset(CustomPricingLiteLLMParams.model_fields) + +_IMAGE_TOKEN_RATE_KEYS: Final[tuple[str, ...]] = ( + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_image_token", + "output_cost_per_image_token", +) + # Pre-resolved service-tier cost-key suffixes (e.g. "_priority"). Used per # request in the cost-calc path, so the f-strings are built once here instead # of being rebuilt for every model_info key on every call. Longest-first so a @@ -826,6 +836,53 @@ def _get_cost_per_unit(model_info: ModelInfo, cost_key: str, default_value: floa return default_value +def deployment_pricing(model_info: ModelInfo | None) -> ModelInfo | None: + """The prices a deployment sets itself, as floats; None when it sets none that parse.""" + if model_info is None: + return None + priced_keys: Final = tuple(key for key in _DEPLOYMENT_PRICING_KEYS if model_info.get(key) is not None) + pricing: Final = MappingProxyType( + { + key: price + for key in priced_keys + if (price := _get_cost_per_unit(model_info, key, default_value=None)) is not None + } + ) + if not pricing: + return None + return cast(ModelInfo, pricing) # cast-ok: a read-only subset of ModelInfo pricing keys, values validated above + + +def prices_tokens(model_info: ModelInfo) -> bool: + """Whether the price table carries any token rate, so a token-priced calculator can bill from usage.""" + return any(model_info.get(key) is not None for key in _IMAGE_TOKEN_RATE_KEYS) + + +def flat_image_cost(model_info: ModelInfo | None, image_response: ImageResponse) -> float: + """The per-image price times the images returned; 0.0 when the table sets no per-image price.""" + if model_info is None: + return 0.0 + output_cost_per_image: Final = _get_cost_per_unit(model_info, "output_cost_per_image", default_value=None) or 0.0 + num_images: Final = len(image_response.data) if image_response.data else 0 + return output_cost_per_image * num_images + + +def resolve_image_model_info(model: str, custom_llm_provider: str, model_info: ModelInfo | None) -> ModelInfo: + """The price table an image cost calculator consults for ``model``. + + ``shared_backend_model_info`` keeps deployment prices off the shared ``{provider}/{model}`` key, so + a name lookup alone reads the public rate, and a model only the deployment prices has no entry at all. + """ + if model_info is None: + return get_model_info(model=model, custom_llm_provider=custom_llm_provider) + try: + shared_model_info: Final = get_model_info(model=model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises a bare Exception for an unmapped model + return model_info + resolved: Final[ModelInfo] = {**shared_model_info, **model_info} + return resolved + + def calculate_cache_writing_cost( cache_creation_tokens: int, cache_creation_token_details: CacheCreationTokenDetails | None, @@ -1711,6 +1768,7 @@ def calculate_image_response_cost_from_usage( model: str, image_response: ImageResponse, custom_llm_provider: str, + model_info: ModelInfo | None = None, ) -> float | None: """ Calculate image generation cost from usage metadata when available. @@ -1735,6 +1793,9 @@ def calculate_image_response_cost_from_usage( if prompt_tokens == 0 and completion_tokens == 0 and total_tokens == 0: return None + if model_info is not None and not prices_tokens(model_info): + return None + input_tokens_details: Final[object] = getattr(usage, "input_tokens_details", None) prompt_tokens_details: PromptTokensDetailsWrapper | None = None if input_tokens_details is not None: @@ -1790,6 +1851,7 @@ def calculate_image_response_cost_from_usage( model=model, usage=normalized_usage, custom_llm_provider=custom_llm_provider, + model_info=model_info, ) return prompt_cost + completion_cost @@ -1850,9 +1912,15 @@ class CostCalculatorUtils: size: str | None = None, optional_params: dict | None = None, call_type: str | None = None, + model_info: ModelInfo | None = None, ) -> float: """ Route the image generation cost calculator based on the custom_llm_provider + + ``model_info`` is the deployment's own price table. Its valid prices are laid over the shared + cost-map entry and handed to the provider calculator, so per-image, per-pixel and per-token + deployment prices all apply while provider logic (token-first billing, grounding surcharges, + image counting) stays in one place. An unparseable price is logged and ignored. """ from litellm.cost_calculator import default_image_cost_calculator from litellm.llms.azure_ai.image_generation.cost_calculator import ( @@ -1878,12 +1946,14 @@ class CostCalculatorUtils: quality or completion_response.quality or _requested_image_param(optional_params, "quality") or "standard" ) resolved_n: Final = n if n is not None else (len(completion_response.data) if completion_response.data else 0) + pricing: Final = deployment_pricing(model_info) if custom_llm_provider == litellm.LlmProviders.VERTEX_AI.value: if isinstance(completion_response, ImageResponse): return vertex_ai_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.BEDROCK.value: if isinstance(completion_response, ImageResponse): @@ -1902,6 +1972,7 @@ class CostCalculatorUtils: return recraft_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.AIML.value: from litellm.llms.aiml.image_generation.cost_calculator import ( @@ -1911,6 +1982,7 @@ class CostCalculatorUtils: return aiml_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.COMETAPI.value: from litellm.llms.cometapi.image_generation.cost_calculator import ( @@ -1920,6 +1992,7 @@ class CostCalculatorUtils: return cometapi_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.GEMINI.value: if call_type in ( @@ -1933,6 +2006,7 @@ class CostCalculatorUtils: return gemini_image_edit_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) from litellm.llms.gemini.image_generation.cost_calculator import ( cost_calculator as gemini_image_cost_calculator, @@ -1941,6 +2015,7 @@ class CostCalculatorUtils: return gemini_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.AZURE_AI.value: return azure_ai_image_cost_calculator( @@ -1949,6 +2024,7 @@ class CostCalculatorUtils: size=resolved_size, n=resolved_n, optional_params=optional_params, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.FAL_AI.value: from litellm.llms.fal_ai.cost_calculator import ( @@ -1959,6 +2035,7 @@ class CostCalculatorUtils: model=model, image_response=completion_response, optional_params=optional_params, + model_info=pricing, ) elif custom_llm_provider == litellm.LlmProviders.RUNWAYML.value: from litellm.llms.runwayml.cost_calculator import ( @@ -1968,6 +2045,7 @@ class CostCalculatorUtils: return runwayml_image_cost_calculator( model=model, image_response=completion_response, + model_info=pricing, ) elif ( custom_llm_provider == litellm.LlmProviders.OPENAI.value @@ -1984,6 +2062,7 @@ class CostCalculatorUtils: model=model, image_response=completion_response, custom_llm_provider=custom_llm_provider, + model_info=pricing, ) # Fall through to default for DALL-E models return default_image_cost_calculator( @@ -1993,6 +2072,7 @@ class CostCalculatorUtils: n=resolved_n, size=resolved_size, optional_params=optional_params, + model_info=pricing, ) else: return default_image_cost_calculator( @@ -2002,5 +2082,6 @@ class CostCalculatorUtils: n=resolved_n, size=resolved_size, optional_params=optional_params, + model_info=pricing, ) return 0.0 diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index c9933422cc3..c44c80bc0a0 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -82,7 +82,7 @@ def _rejected_image_fetch(url: str, verdict: SSRFError) -> "litellm.ImageFetchEr verbose_logger.warning("Image fetch of %s rejected before any request went out: %s", url, verdict) return litellm.ImageFetchError( "Error: Unable to fetch image from URL. The proxy could not resolve this host or its URL policy rejected it; " - f"an admin can check the proxy log and `user_url_allowed_hosts` in general_settings. url={url}" + f"an admin can check the proxy log and `user_url_allowed_hosts` in litellm_settings. url={url}" ) diff --git a/litellm/litellm_core_utils/provider_affinity.py b/litellm/litellm_core_utils/provider_affinity.py new file mode 100644 index 00000000000..31cd9a7ff69 --- /dev/null +++ b/litellm/litellm_core_utils/provider_affinity.py @@ -0,0 +1,98 @@ +import re +from collections.abc import Mapping +from typing import Final + +from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY + +_HTTP_HEADER_NAME_PATTERN: Final = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +_FORBIDDEN_AFFINITY_HEADERS: Final = frozenset( + { + "api-key", + "authorization", + "connection", + "content-length", + "content-type", + "cookie", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "set-cookie", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "www-authenticate", + "x-api-key", + "x-goog-api-key", + } +) + + +def validate_provider_affinity_header_name(header: str) -> str: + if not _HTTP_HEADER_NAME_PATTERN.fullmatch(header): + raise ValueError("provider_affinity_header must be a valid HTTP header name") + if header.lower() in _FORBIDDEN_AFFINITY_HEADERS: + raise ValueError("provider_affinity_header cannot be an authentication, cookie, or transport header") + return header + + +def _get_value(value: object, key: str) -> object | None: + if isinstance(value, Mapping): + return value.get(key) + return getattr(value, key, None) + + +def _get_provider_affinity_header_name(litellm_params: object | None) -> str | None: + header: Final = _get_value(litellm_params, "provider_affinity_header") if litellm_params is not None else None + if header is None: + return None + if not isinstance(header, str): + raise TypeError("provider_affinity_header must be a string") + return validate_provider_affinity_header_name(header) + + +def get_stable_session_id(litellm_params: object | None) -> str | None: + if litellm_params is None: + return None + + direct_session_id: Final = _get_value(litellm_params, "session_id") + if direct_session_id: + return str(direct_session_id) + + metadata_values: Final[tuple[object, ...]] = tuple( + value for key in ("metadata", "litellm_metadata") if (value := _get_value(litellm_params, key)) is not None + ) + has_generated_session_id: Final = any( + isinstance(metadata, Mapping) and metadata.get(SESSION_ID_GENERATED_METADATA_KEY) + for metadata in metadata_values + ) + + litellm_session_id: Final = _get_value(litellm_params, "litellm_session_id") + if litellm_session_id and not has_generated_session_id: + return str(litellm_session_id) + + for metadata in metadata_values: + if ( + isinstance(metadata, Mapping) + and not metadata.get(SESSION_ID_GENERATED_METADATA_KEY) + and (value := metadata.get("session_id")) + ): + return str(value) + return None + + +def add_provider_affinity_header( # mutable-ok: downstream handlers add auth and signing headers + headers: Mapping[str, object], litellm_params: object | None +) -> dict[str, object]: # mutable-ok: downstream handlers add auth and signing headers + header_name: Final = _get_provider_affinity_header_name(litellm_params) + if header_name is None or any(key.lower() == header_name.lower() for key in headers): + return dict(headers) # mutable-ok: downstream handlers add auth and signing headers + + session_id: Final = get_stable_session_id(litellm_params) + if session_id is None: + return dict(headers) # mutable-ok: downstream handlers add auth and signing headers + if any(character in session_id for character in ("\r", "\n", "\0")): + raise ValueError("session_id cannot contain HTTP header control characters") + return {**headers, header_name: session_id} # mutable-ok: downstream handlers add auth and signing headers diff --git a/litellm/litellm_core_utils/realtime_errors.py b/litellm/litellm_core_utils/realtime_errors.py index 3c064728a66..07f7148815c 100644 --- a/litellm/litellm_core_utils/realtime_errors.py +++ b/litellm/litellm_core_utils/realtime_errors.py @@ -9,13 +9,20 @@ frame itself fail, which is how a loud failure turns back into a silent one. """ import json -from typing import Final +from types import MappingProxyType +from typing import Final, Protocol from litellm.types.realtime import RealtimeErrorDetail, RealtimeErrorEvent WEBSOCKET_CLOSE_REASON_MAX_BYTES: Final = 123 +class _ClientWebSocket(Protocol): + async def send_text(self, data: str) -> None: ... + + async def close(self, code: int = ..., reason: str | None = ...) -> None: ... + + def realtime_error_event(message: str, error_type: str) -> str: detail: Final[RealtimeErrorDetail] = {"type": error_type, "message": message} event: Final[RealtimeErrorEvent] = {"type": "error", "error": detail} @@ -37,3 +44,28 @@ def client_close_code(upstream_code: int) -> int: if upstream_code in EXTERNAL_CLOSE_CODES or 3000 <= upstream_code < 5000: return upstream_code return int(CloseCode.INTERNAL_ERROR) + + +def upstream_handshake_close_code(status_code: int) -> int: + from websockets.frames import CloseCode + + refusal_codes: Final = MappingProxyType( + { + 401: int(CloseCode.POLICY_VIOLATION), + 403: int(CloseCode.POLICY_VIOLATION), + 429: int(CloseCode.TRY_AGAIN_LATER), + } + ) + return refusal_codes.get(status_code, int(CloseCode.INTERNAL_ERROR)) + + +async def close_after_upstream_handshake_refusal(websocket: _ClientWebSocket, status_code: int) -> None: + message: Final = f"Upstream realtime handshake rejected with HTTP {status_code}" + try: + await websocket.send_text(realtime_error_event(message, error_type="server_error")) + except Exception: # noqa: BLE001 # best-effort notice: a dead client socket must not skip the close below + pass + await websocket.close( + code=upstream_handshake_close_code(status_code), + reason=websocket_close_reason(message, fallback="Upstream handshake rejected"), + ) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 025db65a7ce..aa4e0cf5495 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -165,6 +165,14 @@ class _UsageSummary(TypedDict): cost: float | None +def _reports_prompt_side_usage(usage_summary: "_UsageSummary") -> bool: + return ( + (usage_summary["prompt_tokens"] or 0) > 0 + or (usage_summary["cache_creation_input_tokens"] or 0) > 0 + or (usage_summary["cache_read_input_tokens"] or 0) > 0 + ) + + def capture_cache_creation_token_details( prompt_tokens_details: PromptTokensDetailsWrapper | None, current: CacheCreationTokenDetails | None, @@ -888,11 +896,11 @@ class ChunkProcessor: if usage_chunk_dict["completion_tokens"] is not None and usage_chunk_dict["completion_tokens"] > 0: completion_usage_updates += 1 if usage_chunk_dict["cache_creation_input_tokens"] is not None and ( - usage_chunk_dict["cache_creation_input_tokens"] > 0 or cache_creation_input_tokens is None + _reports_prompt_side_usage(usage_chunk_dict) or cache_creation_input_tokens is None ): cache_creation_input_tokens = usage_chunk_dict["cache_creation_input_tokens"] if usage_chunk_dict["cache_read_input_tokens"] is not None and ( - usage_chunk_dict["cache_read_input_tokens"] > 0 or cache_read_input_tokens is None + _reports_prompt_side_usage(usage_chunk_dict) or cache_read_input_tokens is None ): cache_read_input_tokens = usage_chunk_dict["cache_read_input_tokens"] if usage_chunk_dict["completion_tokens_details"] is not None: diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index b94d5a6886d..6c87ef4a3de 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -336,7 +336,7 @@ def validate_url(url: str) -> tuple[str, str]: raise SSRFError( f"URL targets a blocked address ({resolved_ip}). " "If this is a legitimate internal service, add the host " - "to `user_url_allowed_hosts` in general_settings." + "to `user_url_allowed_hosts` in litellm_settings." ) # For HTTPS with SSL verification enabled, TLS certificate validation diff --git a/litellm/llms/aiml/image_generation/cost_calculator.py b/litellm/llms/aiml/image_generation/cost_calculator.py index 13427dcafc2..abf4216807a 100644 --- a/litellm/llms/aiml/image_generation/cost_calculator.py +++ b/litellm/llms/aiml/image_generation/cost_calculator.py @@ -1,19 +1,22 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ AI/ML flux image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.AIML.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/azure/realtime/handler.py b/litellm/llms/azure/realtime/handler.py index 146915dd6fd..df74975ad0f 100644 --- a/litellm/llms/azure/realtime/handler.py +++ b/litellm/llms/azure/realtime/handler.py @@ -8,11 +8,15 @@ from collections.abc import Mapping from types import MappingProxyType from typing import Any, Final, Protocol, cast -from litellm._logging import _redact_string, verbose_proxy_logger +from litellm._logging import verbose_proxy_logger from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES from litellm.types.realtime import RealtimeQueryParams from ....litellm_core_utils.litellm_logging import Logging as LiteLLMLogging +from ....litellm_core_utils.realtime_errors import ( + close_after_upstream_handshake_refusal, + realtime_error_event, +) from ....litellm_core_utils.realtime_streaming import ( RealTimeStreaming, ScopedWebSocket, @@ -49,7 +53,9 @@ def azure_realtime_protocol_for_client( class _ProxyClientWebSocket(Protocol): - """Client-facing websocket handle: this path only closes it after a failed handshake.""" + """Client-facing websocket handle: this path only writes to it after a failed handshake.""" + + async def send_text(self, data: str) -> None: ... async def close(self, code: int = ..., reason: str | None = ...) -> None: ... @@ -181,7 +187,16 @@ class AzureOpenAIRealtime(AzureChatCompletion): ) await realtime_streaming.bidirectional_forward() - except websockets.exceptions.InvalidStatusCode as e: - await websocket.close(code=e.status_code, reason=_redact_string(str(e))) + except websockets.exceptions.InvalidStatus as e: + verbose_proxy_logger.exception("Error in AzureOpenAIRealtime.async_realtime") + await close_after_upstream_handshake_refusal(websocket, e.response.status_code) except Exception: verbose_proxy_logger.exception("Error in AzureOpenAIRealtime.async_realtime") + try: + await websocket.send_text(realtime_error_event("Internal server error", error_type="server_error")) + except Exception: # noqa: BLE001 # best-effort notice: a dead client socket must not skip the close below + pass + try: + await websocket.close(code=1011, reason="Internal server error") + except Exception: # noqa: BLE001 # the lower layer may have closed the socket already; closing twice is not an error + pass diff --git a/litellm/llms/azure_ai/image_generation/cost_calculator.py b/litellm/llms/azure_ai/image_generation/cost_calculator.py index 35d0f4fb6c3..08b732197f3 100644 --- a/litellm/llms/azure_ai/image_generation/cost_calculator.py +++ b/litellm/llms/azure_ai/image_generation/cost_calculator.py @@ -3,9 +3,22 @@ from typing import Any, Final import litellm from litellm.litellm_core_utils.llm_cost_calc.utils import ( + _get_cost_per_unit, calculate_image_response_cost_from_usage, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse +from litellm.types.utils import ImageResponse, ModelInfo + + +def _input_cost_per_pixel(resolved: ModelInfo) -> float: + deployment_price: Final = _get_cost_per_unit(resolved, "input_cost_per_pixel", default_value=None) + if deployment_price is not None: + return deployment_price + model_cost_key: Final = resolved.get("key") + shared_entry: Final = litellm.model_cost.get(model_cost_key) if model_cost_key is not None else None + if shared_entry is None: + return 0.0 + return shared_entry.get("input_cost_per_pixel") or 0.0 def cost_calculator( @@ -14,13 +27,15 @@ def cost_calculator( size: str | None = None, n: int | None = None, optional_params: Mapping[str, object] | None = None, + model_info: ModelInfo | None = None, ) -> float: """ Azure AI image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.AZURE_AI.value, + model_info=model_info, ) if isinstance(image_response, ImageResponse): @@ -28,6 +43,7 @@ def cost_calculator( model=model, image_response=image_response, custom_llm_provider=litellm.LlmProviders.AZURE_AI.value, + model_info=_model_info, ) if token_based_cost is not None: return token_based_cost @@ -37,9 +53,7 @@ def cost_calculator( if output_cost_per_image: return output_cost_per_image * num_images - model_cost: Final = litellm.model_cost[_model_info["key"]] - input_cost_per_pixel: Final[float] = model_cost.get("input_cost_per_pixel") or 0.0 - if input_cost_per_pixel: + if _input_cost_per_pixel(_model_info): from litellm.cost_calculator import default_image_cost_calculator width: Final = optional_params.get("width") if optional_params else None @@ -50,10 +64,11 @@ def cost_calculator( else size or image_response.size ) return default_image_cost_calculator( - model=_model_info["key"], + model=_model_info.get("key", model), custom_llm_provider=litellm.LlmProviders.AZURE_AI.value, size=pixel_size, n=num_images, + model_info=model_info, ) return 0.0 diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index 4f94cec0973..a4964de46b8 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -12,6 +12,7 @@ from litellm.types.videos.main import VideoCreateOptionalRequestParams if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.types.videos.main import CharacterObject as _CharacterObject from litellm.types.videos.main import VideoObject as _VideoObject @@ -269,6 +270,7 @@ class BaseVideoConfig(ABC): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: pass @@ -277,6 +279,7 @@ class BaseVideoConfig(ABC): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "AsyncHTTPHandler | None" = None, ) -> VideoObject: """Async transform video status retrieve response.""" return self.transform_video_status_retrieve_response( diff --git a/litellm/llms/cometapi/image_generation/cost_calculator.py b/litellm/llms/cometapi/image_generation/cost_calculator.py index 0ad9f75c45f..8f767cf311e 100644 --- a/litellm/llms/cometapi/image_generation/cost_calculator.py +++ b/litellm/llms/cometapi/image_generation/cost_calculator.py @@ -1,19 +1,22 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ CometAPI image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.COMETAPI.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index fcc05e54bc5..312f46bb021 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -845,6 +845,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + content=content, ) finally: await new_client.aclose() @@ -985,6 +986,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + content=content, ) finally: await new_client.aclose() @@ -1051,6 +1053,7 @@ class AsyncHTTPHandler: params=params, headers=headers, stream=stream, + content=content, ) finally: await new_client.aclose() @@ -1708,7 +1711,7 @@ class HTTPHandler: def get_async_httpx_client( - llm_provider: LlmProviders | httpxSpecialProvider, + llm_provider: LlmProviders | httpxSpecialProvider | str, params: dict | None = None, shared_session: Optional["ClientSession"] = None, ) -> AsyncHTTPHandler: diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 333ce523e34..8cbc28362a8 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -45,7 +45,11 @@ from litellm.litellm_core_utils.audio_utils.subtitle_utils import ( ) from litellm.litellm_core_utils.get_litellm_params import AWS_CREDENTIAL_KWARGS_KEYS from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields -from litellm.litellm_core_utils.realtime_errors import realtime_error_event, websocket_close_reason +from litellm.litellm_core_utils.realtime_errors import ( + close_after_upstream_handshake_refusal, + realtime_error_event, + websocket_close_reason, +) from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.anthropic_messages.transformation import ( @@ -2853,7 +2857,7 @@ class BaseLLMHTTPHandler: id(shared_session) if shared_session else None, ) async_httpx_client = get_async_httpx_client( - llm_provider=litellm.LlmProviders(custom_llm_provider), + llm_provider=custom_llm_provider, params={"ssl_verify": litellm_params.get("ssl_verify", None)}, shared_session=shared_session, ) @@ -6384,9 +6388,9 @@ class BaseLLMHTTPHandler: await realtime_streaming.bidirectional_forward() - except websockets.exceptions.InvalidStatusCode as e: + except websockets.exceptions.InvalidStatus as e: verbose_logger.exception("Error connecting to backend: %s", e) - await websocket.close(code=e.status_code, reason=_redact_string(str(e))) + await close_after_upstream_handshake_refusal(websocket, e.response.status_code) except Exception as e: verbose_logger.exception("Error connecting to backend: %s", e) redacted_error: Final = _redact_string(str(e)) @@ -6799,9 +6803,9 @@ class BaseLLMHTTPHandler: ) return await streaming.bidirectional_forward() - except websockets.exceptions.InvalidStatusCode as e: + except websockets.exceptions.InvalidStatus as e: verbose_logger.exception("Error connecting to responses WS backend: %s", e) - await websocket.close(code=e.status_code, reason=_redact_string(str(e))) + await close_after_upstream_handshake_refusal(websocket, e.response.status_code) except Exception as e: verbose_logger.exception("Error in responses WS: %s", e) try: @@ -8808,6 +8812,7 @@ class BaseLLMHTTPHandler: raw_response=response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, + client=sync_httpx_client, ) except Exception as e: @@ -8897,6 +8902,7 @@ class BaseLLMHTTPHandler: raw_response=response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, + client=async_httpx_client, ) except Exception as e: diff --git a/litellm/llms/edenai/videos/transformation.py b/litellm/llms/edenai/videos/transformation.py index 25c7bcf24ea..31572e9e5fe 100644 --- a/litellm/llms/edenai/videos/transformation.py +++ b/litellm/llms/edenai/videos/transformation.py @@ -21,6 +21,7 @@ from ..common_utils import EdenAIException, authorized_headers, endpoint_url, re if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import HTTPHandler def _usage_with_reported_cost( @@ -110,6 +111,7 @@ class EdenAIVideoConfig(OpenAIVideoConfig): raw_response: httpx.Response, logging_obj: "LiteLLMLoggingObj", custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: raw_response.raise_for_status() # the shared GET helpers return error bodies instead of raising video: Final = super().transform_video_status_retrieve_response( diff --git a/litellm/llms/fal_ai/chat/transformation.py b/litellm/llms/fal_ai/chat/transformation.py index 2c5af6538ab..164b660b21a 100644 --- a/litellm/llms/fal_ai/chat/transformation.py +++ b/litellm/llms/fal_ai/chat/transformation.py @@ -115,13 +115,13 @@ class FalAIChatConfig(BaseConfig): return list(("reasoning_effort", "temperature", "top_p")) # mutable-ok: inherited contract returns a list def _map_reasoning_effort(self, value: object, model: str, drop_params: bool) -> bool | None: - if value in REASONING_DISABLED_EFFORTS: + if isinstance(value, str) and value in REASONING_DISABLED_EFFORTS: return False - if value in REASONING_ENABLED_EFFORTS: + if isinstance(value, str) and value in REASONING_ENABLED_EFFORTS: return True if drop_params: return None - raise FalAIError(status_code=400, message=f"Unsupported reasoning_effort '{value}' for {model}") + raise FalAIError(status_code=400, message=f"Unsupported reasoning_effort {value!r} for {model}") def _translate_param(self, param: str, value: object, model: str, drop_params: bool) -> tuple[str, object] | None: if param in ("temperature", "top_p"): diff --git a/litellm/llms/fal_ai/cost_calculator.py b/litellm/llms/fal_ai/cost_calculator.py index 31f0995bf9f..519a11de13c 100644 --- a/litellm/llms/fal_ai/cost_calculator.py +++ b/litellm/llms/fal_ai/cost_calculator.py @@ -7,7 +7,8 @@ from typing import Final from pydantic import TypeAdapter import litellm -from litellm.types.utils import ImageObject, ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import deployment_pricing, resolve_image_model_info +from litellm.types.utils import ImageObject, ImageResponse, ModelInfo FAL_KEYED_PRICING_DEFAULT_QUALITY: Final[str] = "high" _DEFAULT_KEYED_DIMENSIONS: Final[tuple[int, int]] = (1024, 768) @@ -135,12 +136,18 @@ def _entry(key: str) -> Mapping[str, object] | None: return _OBJECT_MAP.validate_python(raw_entry) +def _resolution_key(resolution: object) -> str | None: + if isinstance(resolution, bool) or not isinstance(resolution, (int, str)): + return None + return str(resolution) + + def fal_ai_passthrough_cost(model: str, request_body: Mapping[str, object]) -> float | None: entry: Final = _entry(f"{litellm.LlmProviders.FAL_AI.value}/{model}") if entry is None: return None - resolution: Final = request_body.get("resolution") - keyed_cost: Final = entry.get(f"output_cost_per_image_{resolution}") if isinstance(resolution, int) else None + resolution: Final = _resolution_key(request_body.get("resolution")) + keyed_cost: Final = entry.get(f"output_cost_per_image_{resolution}") if resolution is not None else None cost: Final = keyed_cost if isinstance(keyed_cost, (int, float)) else entry.get("output_cost_per_image") return float(cost) if isinstance(cost, (int, float)) else None @@ -149,6 +156,7 @@ def cost_calculator( model: str, image_response: object, optional_params: Mapping[str, object] | None = None, + model_info: ModelInfo | None = None, ) -> float: """ fal.ai image generation cost calculator @@ -156,8 +164,14 @@ def cost_calculator( if not isinstance(image_response, ImageResponse): raise ValueError(f"image_response must be of type ImageResponse got type={type(image_response)}") normalized_model: Final = model.removeprefix(f"{litellm.LlmProviders.FAL_AI.value}/") - params: Final[Mapping[str, object]] = optional_params or MappingProxyType({}) images: Final = tuple(image_response.data or ()) + deployment_prices: Final = deployment_pricing(model_info) + deployment_cost_per_image: Final = ( + None if deployment_prices is None else deployment_prices.get("output_cost_per_image") + ) + if deployment_cost_per_image is not None: + return deployment_cost_per_image * len(images) + params: Final[Mapping[str, object]] = optional_params or MappingProxyType({}) keyed_costs: Final = tuple( _keyed_cost_per_image( model=normalized_model, @@ -168,15 +182,16 @@ def cost_calculator( ) if not any(cost is None for cost in keyed_costs): return sum(cost for cost in keyed_costs if cost is not None) - model_info: Final = litellm.get_model_info( + resolved_model_info: Final = resolve_image_model_info( model=normalized_model, custom_llm_provider=litellm.LlmProviders.FAL_AI.value, + model_info=deployment_prices, ) - raw_output_cost_per_image: Final = model_info.get("output_cost_per_image") + raw_output_cost_per_image: Final = resolved_model_info.get("output_cost_per_image") output_cost_per_image: Final = ( float(raw_output_cost_per_image) if isinstance(raw_output_cost_per_image, (int, float)) else 0.0 ) - raw_output_cost_per_pixel: Final = model_info.get("output_cost_per_pixel") + raw_output_cost_per_pixel: Final = resolved_model_info.get("output_cost_per_pixel") output_cost_per_pixel: Final = ( float(raw_output_cost_per_pixel) if isinstance(raw_output_cost_per_pixel, (int, float)) else None ) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 51082a6773b..e46199dd89f 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -104,7 +104,10 @@ def _profile_for_model(model: str) -> _ModelProfile: def _resolution_for_short_side(short_side: int, profile: _ModelProfile) -> str: - return next(resolution for threshold, resolution in profile.resolution_tiers if short_side <= threshold) + return next( + (resolution for threshold, resolution in profile.resolution_tiers if short_side <= threshold), + profile.resolution_tiers[-1][1], + ) def _model_path_from_request_url(raw_response: httpx.Response) -> str | None: @@ -227,6 +230,9 @@ def _response_string(response_data: Mapping[str, object], key: str, default: str return value if isinstance(value, str) else default +_RESULT_HEADERS_NOT_FORWARDED: Final[frozenset[str]] = frozenset({"host", "content-length", "transfer-encoding"}) + + def _result_request( raw_response: httpx.Response, response_data: Mapping[str, object], @@ -234,14 +240,12 @@ def _result_request( if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": return None result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") + encoding: Final[str] = raw_response.request.headers.encoding result_headers: Final[Mapping[str, str]] = MappingProxyType( { - key: value - for key, value in ( - ("Authorization", raw_response.request.headers.get("Authorization")), - ("Content-Type", raw_response.request.headers.get("Content-Type")), - ) - if value is not None + key.decode(encoding): value.decode(encoding) + for key, value in raw_response.request.headers.raw + if key.decode(encoding).lower() not in _RESULT_HEADERS_NOT_FORWARDED } ) return result_url, result_headers @@ -351,6 +355,8 @@ class FalAIVideoConfig(BaseVideoConfig): duration: Final[str | None] = _duration_value(seconds) if duration is None: raise ValueError("fal.ai seconds must be a numeric value") + if duration == "auto": + return MappingProxyType({}) if profile.integer_duration else MappingProxyType({"duration": duration}) return MappingProxyType({"duration": int(duration) if profile.integer_duration else duration}) def validate_environment( @@ -458,9 +464,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: object, custom_llm_provider: str | None = None, + client: HTTPHandler | None = None, ) -> VideoObject: response_data: Final[Mapping[str, object]] = _response_data(raw_response) - result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data) + result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data, client) return _status_video_object( response_data=response_data, raw_response=raw_response, @@ -472,15 +479,20 @@ class FalAIVideoConfig(BaseVideoConfig): self, raw_response: httpx.Response, response_data: Mapping[str, object], + client: HTTPHandler | None, ) -> str | None: result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) if result_request is None: return None result_url, result_headers = result_request - result_response: Final[httpx.Response] = self._sync_client_factory().get( - url=result_url, - headers=result_headers, - ) + result_client: Final[HTTPHandler] = client if client is not None else self._sync_client_factory() + try: + result_response: Final[httpx.Response] = result_client.get( + url=result_url, + headers=dict(result_headers), # mutable-ok: HTTPHandler.get only accepts a dict + ) + except httpx.TransportError: + return None return _terminal_result_error(result_response) async def async_transform_video_status_retrieve_response( @@ -488,9 +500,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: object, custom_llm_provider: str | None = None, + client: AsyncHTTPHandler | None = None, ) -> VideoObject: response_data: Final[Mapping[str, object]] = _response_data(raw_response) - result_error: Final[str | None] = await self._fetch_result_error_async(raw_response, response_data) + result_error: Final[str | None] = await self._fetch_result_error_async(raw_response, response_data, client) return _status_video_object( response_data=response_data, raw_response=raw_response, @@ -502,15 +515,20 @@ class FalAIVideoConfig(BaseVideoConfig): self, raw_response: httpx.Response, response_data: Mapping[str, object], + client: AsyncHTTPHandler | None, ) -> str | None: result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) if result_request is None: return None result_url, result_headers = result_request - result_response: Final[httpx.Response] = await self._async_client_factory().get( - url=result_url, - headers=result_headers, - ) + result_client: Final[AsyncHTTPHandler] = client if client is not None else self._async_client_factory() + try: + result_response: Final[httpx.Response] = await result_client.get( + url=result_url, + headers=dict(result_headers), # mutable-ok: AsyncHTTPHandler.get only accepts a dict + ) + except httpx.TransportError: + return None return _terminal_result_error(result_response) @staticmethod diff --git a/litellm/llms/gemini/image_edit/cost_calculator.py b/litellm/llms/gemini/image_edit/cost_calculator.py index 956edb849a0..321fbbaeb37 100644 --- a/litellm/llms/gemini/image_edit/cost_calculator.py +++ b/litellm/llms/gemini/image_edit/cost_calculator.py @@ -7,11 +7,13 @@ from typing import Any from litellm.llms.gemini.image_generation.cost_calculator import ( cost_calculator as image_generation_cost_calculator, ) +from litellm.types.utils import ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ Gemini image edit cost calculator. @@ -22,4 +24,5 @@ def cost_calculator( return image_generation_cost_calculator( model=model, image_response=image_response, + model_info=model_info, ) diff --git a/litellm/llms/gemini/image_generation/cost_calculator.py b/litellm/llms/gemini/image_generation/cost_calculator.py index ea0e77e1b81..e3232f8fb7b 100644 --- a/litellm/llms/gemini/image_generation/cost_calculator.py +++ b/litellm/llms/gemini/image_generation/cost_calculator.py @@ -4,24 +4,26 @@ Google AI Image Generation Cost Calculator from typing import Any, Final -import litellm from litellm.litellm_core_utils.llm_cost_calc.utils import ( calculate_image_response_cost_from_usage, calculate_image_response_web_search_cost, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ Google AI Image Generation Cost Calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider="gemini", + model_info=model_info, ) if not isinstance(image_response, ImageResponse): @@ -37,6 +39,7 @@ def cost_calculator( model=model, image_response=image_response, custom_llm_provider="gemini", + model_info=_model_info, ) if token_based_cost is not None: return token_based_cost + web_search_cost diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index a44717eb659..8a3894cf7fb 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -26,6 +26,7 @@ from litellm.types.videos.utils import ( if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import HTTPHandler from ...base_llm.chat.transformation import BaseLLMException as _BaseLLMException @@ -386,6 +387,7 @@ class GeminiVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: """ Transform the Veo operation status response. diff --git a/litellm/llms/openai/image_generation/cost_calculator.py b/litellm/llms/openai/image_generation/cost_calculator.py index 938a0a57f2a..350fdbaee2d 100644 --- a/litellm/llms/openai/image_generation/cost_calculator.py +++ b/litellm/llms/openai/image_generation/cost_calculator.py @@ -9,27 +9,37 @@ from typing import Final from litellm import verbose_logger from litellm.litellm_core_utils.llm_cost_calc.utils import ( calculate_image_response_cost_from_usage, + flat_image_cost, generic_cost_per_token, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse, Usage +from litellm.types.utils import ImageResponse, ModelInfo, Usage def cost_calculator( model: str, image_response: ImageResponse, custom_llm_provider: str | None = None, + model_info: ModelInfo | None = None, ) -> float: """Calculate cost for OpenAI gpt-image models (token-based pricing).""" + provider: Final = custom_llm_provider or "openai" + price_table: Final = ( + None + if model_info is None + else resolve_image_model_info(model=model, custom_llm_provider=provider, model_info=model_info) + ) + usage: Final = getattr(image_response, "usage", None) if usage is None: verbose_logger.debug("No usage data available for %s, cannot calculate token-based cost", model) - return 0.0 - - provider: Final = custom_llm_provider or "openai" + return flat_image_cost(price_table, image_response) # A chat Usage with an explicit output breakdown: cost via generic_cost_per_token. if isinstance(usage, Usage) and usage.completion_tokens_details is not None: - prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider=provider) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, usage=usage, custom_llm_provider=provider, model_info=price_table + ) return prompt_cost + completion_cost # ImageUsage / ResponseAPIUsage: reuse the shared helper (same path as @@ -38,7 +48,7 @@ def cost_calculator( # does not itemize output and splitting text/image when it does. if getattr(usage, "input_tokens", None) is not None: token_based_cost: Final = calculate_image_response_cost_from_usage( - model=model, image_response=image_response, custom_llm_provider=provider + model=model, image_response=image_response, custom_llm_provider=provider, model_info=price_table ) if token_based_cost is not None: return token_based_cost @@ -46,7 +56,9 @@ def cost_calculator( # Fallback: a Usage with no output breakdown that the image helper can't read — # cost via generic_cost_per_token (text rate) instead of returning 0.0. if isinstance(usage, Usage): - prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider=provider) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, usage=usage, custom_llm_provider=provider, model_info=price_table + ) return prompt_cost + completion_cost - return 0.0 + return flat_image_cost(price_table, image_response) diff --git a/litellm/llms/openai/realtime/handler.py b/litellm/llms/openai/realtime/handler.py index e3ecbac1a53..bdc3a6c7908 100644 --- a/litellm/llms/openai/realtime/handler.py +++ b/litellm/llms/openai/realtime/handler.py @@ -12,6 +12,7 @@ from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES from litellm.types.realtime import RealtimeQueryParams from ....litellm_core_utils.litellm_logging import Logging as LiteLLMLogging +from ....litellm_core_utils.realtime_errors import close_after_upstream_handshake_refusal from ....litellm_core_utils.realtime_streaming import ( RealtimeEventNormalizer, RealTimeStreaming, @@ -175,8 +176,8 @@ class OpenAIRealtime(OpenAIChatCompletion): ) await realtime_streaming.bidirectional_forward() - except websockets.exceptions.InvalidStatusCode as e: - await websocket.close(code=e.status_code, reason=_redact_string(str(e))) + except websockets.exceptions.InvalidStatus as e: + await close_after_upstream_handshake_refusal(websocket, e.response.status_code) except Exception as e: try: await websocket.close(code=1011, reason=_redact_string(f"Internal server error: {e}")) diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index 9a4b030993f..cfd4ca24ae7 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -28,6 +28,7 @@ from litellm.types.videos.utils import ( if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.custom_httpx.http_handler import HTTPHandler from ...base_llm.chat.transformation import BaseLLMException as _BaseLLMException @@ -437,6 +438,7 @@ class OpenAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: """ Transform the OpenAI video retrieve response. diff --git a/litellm/llms/recraft/cost_calculator.py b/litellm/llms/recraft/cost_calculator.py index 2866d8e1d96..221d4a35913 100644 --- a/litellm/llms/recraft/cost_calculator.py +++ b/litellm/llms/recraft/cost_calculator.py @@ -1,19 +1,22 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ Recraft image generation cost calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.RECRAFT.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/runwayml/cost_calculator.py b/litellm/llms/runwayml/cost_calculator.py index fdd4b904b60..07f7d564ac2 100644 --- a/litellm/llms/runwayml/cost_calculator.py +++ b/litellm/llms/runwayml/cost_calculator.py @@ -1,12 +1,14 @@ from typing import Any, Final import litellm -from litellm.types.utils import ImageResponse +from litellm.litellm_core_utils.llm_cost_calc.utils import resolve_image_model_info +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: Any, + model_info: ModelInfo | None = None, ) -> float: """ RunwayML image generation cost calculator. @@ -14,9 +16,10 @@ def cost_calculator( RunwayML charges per image generated, not per pixel. Pricing is stored in model_prices_and_context_window.json with output_cost_per_image. """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider=litellm.LlmProviders.RUNWAYML.value, + model_info=model_info, ) output_cost_per_image: Final[float] = _model_info.get("output_cost_per_image") or 0.0 num_images: int = 0 diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index c7696a1cb29..1abdece44f2 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -616,6 +616,7 @@ class RunwayMLVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: HTTPHandler | None = None, ) -> VideoObject: """ Transform the RunwayML video status retrieve response. diff --git a/litellm/llms/vertex_ai/image_generation/cost_calculator.py b/litellm/llms/vertex_ai/image_generation/cost_calculator.py index f3117e65681..6db4f02ee5a 100644 --- a/litellm/llms/vertex_ai/image_generation/cost_calculator.py +++ b/litellm/llms/vertex_ai/image_generation/cost_calculator.py @@ -4,24 +4,26 @@ Vertex AI Image Generation Cost Calculator from typing import Final -import litellm from litellm.litellm_core_utils.llm_cost_calc.utils import ( calculate_image_response_cost_from_usage, calculate_image_response_web_search_cost, + resolve_image_model_info, ) -from litellm.types.utils import ImageResponse +from litellm.types.utils import ImageResponse, ModelInfo def cost_calculator( model: str, image_response: ImageResponse, + model_info: ModelInfo | None = None, ) -> float: """ Vertex AI Image Generation Cost Calculator """ - _model_info: Final = litellm.get_model_info( + _model_info: Final = resolve_image_model_info( model=model, custom_llm_provider="vertex_ai", + model_info=model_info, ) web_search_cost: Final = calculate_image_response_web_search_cost( @@ -34,6 +36,7 @@ def cost_calculator( model=model, image_response=image_response, custom_llm_provider="vertex_ai", + model_info=_model_info, ) if token_based_cost is not None: return token_based_cost + web_search_cost diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index dc9caa13224..6c29059f0a9 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from litellm.llms.base_llm.chat.transformation import ( BaseLLMException as _BaseLLMException, ) + from litellm.llms.custom_httpx.http_handler import HTTPHandler LiteLLMLoggingObj = _LiteLLMLoggingObj BaseLLMException = _BaseLLMException @@ -491,6 +492,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, + client: "HTTPHandler | None" = None, ) -> VideoObject: """ Transform the Veo operation status response. diff --git a/litellm/main.py b/litellm/main.py index 7d231a1bf7a..7f4b34d28a0 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -78,8 +78,9 @@ from litellm.litellm_core_utils.chat_completion_agentic_loop import ( from litellm.litellm_core_utils.completion_timeout import CompletionTimeout from litellm.litellm_core_utils.dd_tracing import tracer from litellm.litellm_core_utils.get_litellm_params import ( - FORWARDED_KWARGS_KEYS, + AWS_CREDENTIAL_KWARGS_KEYS, OPTIONAL_KWARGS_KEYS, + PROVIDER_AFFINITY_HEADER_KWARG_KEY, ) from litellm.litellm_core_utils.get_provider_specific_headers import ( ProviderSpecificHeaderUtils, @@ -96,6 +97,7 @@ from litellm.litellm_core_utils.mock_functions import ( from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_content_from_model_response, ) +from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header from litellm.litellm_core_utils.request_timeout_resolver import ( get_configured_request_timeout, ) @@ -5644,8 +5646,30 @@ def completion( gigachat_scope=kwargs.get("gigachat_scope"), gigachat_auth_url=kwargs.get("gigachat_auth_url"), gigachat_access_token=kwargs.get("gigachat_access_token"), - **{key: kwargs[key] for key in FORWARDED_KWARGS_KEYS if key in kwargs}, + **{ + key: kwargs[key] + for key in (*AWS_CREDENTIAL_KWARGS_KEYS, PROVIDER_AFFINITY_HEADER_KWARG_KEY) + if key in kwargs + }, ) + if litellm_params.get("provider_affinity_header") is not None: + try: + headers = add_provider_affinity_header( + headers=headers or litellm.headers or MappingProxyType({}), + litellm_params=MappingProxyType( + { + "provider_affinity_header": litellm_params["provider_affinity_header"], + "litellm_session_id": kwargs.get("litellm_session_id"), + "session_id": kwargs.get("session_id"), + "metadata": metadata, + "litellm_metadata": kwargs.get("litellm_metadata"), + } + ), + ) + except ValueError as affinity_error: + raise litellm.BadRequestError( + message=str(affinity_error), model=model, llm_provider=custom_llm_provider + ) from affinity_error cast(LiteLLMLoggingObj, logging).update_environment_variables( model=model, user=user, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 8d68e325198..b48bdd6322c 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -53,13 +53,6 @@ "mode": "image_generation", "output_cost_per_image": 0.04 }, - "1024-x-1024/dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 1.9e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "1024-x-1024/max-steps/stability.stable-diffusion-xl-v1": { "litellm_provider": "bedrock", "max_input_tokens": 77, @@ -67,13 +60,6 @@ "mode": "image_generation", "output_cost_per_image": 0.08 }, - "256-x-256/dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 2.4414e-07, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "512-x-512/50-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, @@ -81,13 +67,6 @@ "mode": "image_generation", "output_cost_per_image": 0.018 }, - "512-x-512/dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 6.86e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "512-x-512/max-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, @@ -578,17 +557,6 @@ "supports_vision": true, "supports_tool_choice": true }, - "amazon.nova-sonic-v1:0": { - "deprecation_date": "2026-09-14", - "input_cost_per_audio_token": 3.4e-06, - "input_cost_per_token": 6e-08, - "litellm_provider": "bedrock", - "mode": "realtime", - "output_cost_per_audio_token": 1.36e-05, - "output_cost_per_token": 2.4e-07, - "supports_audio_input": true, - "supports_audio_output": true - }, "amazon.nova-2-sonic-v1:0": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 3.3e-07, @@ -972,23 +940,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", @@ -1004,23 +955,6 @@ "cache_read_input_token_cost": 1.5e-06, "cache_creation_input_token_cost": 1.875e-05 }, - "anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", @@ -2982,60 +2916,6 @@ "supports_vision": true, "supports_tool_choice": true }, - "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, - "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "apac.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, @@ -3063,23 +2943,6 @@ "input_cost_per_token_batches": 5.5e-07, "output_cost_per_token_batches": 2.75e-06 }, - "apac.anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "apac.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -3457,29 +3320,6 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 1024 }, - "azure_ai/claude-opus-4-1": { - "deprecation_date": "2026-08-05", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "azure_ai", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "azure_ai/claude-sonnet-4-5": { "deprecation_date": "2026-10-19", "cache_creation_input_token_cost": 3.75e-06, @@ -4428,7 +4268,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 6.875e-08 }, "azure/eu/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, @@ -4467,7 +4308,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.375e-08 }, "azure/eu/gpt-5.1": { "deprecation_date": "2027-05-15", @@ -4512,43 +4354,6 @@ "output_cost_per_token_priority": 2.2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.375e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.375e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1.1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.375e-07, @@ -4647,7 +4452,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 2.75e-09 }, "azure/eu/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, @@ -4685,21 +4491,6 @@ "supports_prompt_caching": true, "supports_vision": false }, - "azure/eu/o1-preview-2024-09-12": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_vision": false - }, "azure/eu/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, "deprecation_date": "2026-11-19", @@ -4855,43 +4646,6 @@ "output_cost_per_token_priority": 2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/global/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/global/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, @@ -4967,19 +4721,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "azure/gpt-3.5-turbo-0125": { - "deprecation_date": "2025-03-31", - "input_cost_per_token": 5e-07, - "litellm_provider": "azure", - "max_input_tokens": 16384, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, "azure/gpt-3.5-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, "litellm_provider": "azure_text", @@ -4999,32 +4740,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "azure/gpt-35-turbo-0125": { - "deprecation_date": "2025-05-31", - "input_cost_per_token": 5e-07, - "litellm_provider": "azure", - "max_input_tokens": 16384, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, - "azure/gpt-35-turbo-1106": { - "deprecation_date": "2025-03-31", - "input_cost_per_token": 1e-06, - "litellm_provider": "azure", - "max_input_tokens": 16384, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 2e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, "azure/gpt-35-turbo-16k": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", @@ -5792,40 +5507,6 @@ "supports_system_messages": true, "supports_tool_choice": true }, - "azure/gpt-realtime-2": { - "cache_read_input_audio_token_cost": 4e-07, - "cache_read_input_token_cost": 4e-07, - "deprecation_date": "2026-08-31", - "input_cost_per_audio_token": 3.2e-05, - "input_cost_per_image_token": 5e-06, - "input_cost_per_token": 4e-06, - "litellm_provider": "azure", - "max_input_tokens": 32000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 6.4e-05, - "output_cost_per_token": 2.4e-05, - "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure", - "supported_endpoints": [ - "/v1/realtime" - ], - "supported_modalities": [ - "text", - "image", - "audio" - ], - "supported_output_modalities": [ - "text", - "audio" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "azure/gpt-realtime-2.1": { "cache_creation_input_audio_token_cost": 4e-07, "cache_read_input_audio_token_cost": 4e-07, @@ -6120,46 +5801,8 @@ "input_cost_per_token_batches": 6.25e-07, "output_cost_per_token_batches": 5e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_minimal_reasoning_effort": true - }, - "azure/gpt-5.1-chat-2025-11-13": { - "cache_read_input_token_cost": 1.25e-07, - "cache_read_input_token_cost_priority": 2.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "input_cost_per_token_priority": 2.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "output_cost_per_token_priority": 2e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_native_streaming": true, - "supports_parallel_function_calling": false, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": false, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 6.25e-08 }, "azure/gpt-5.1-codex-2025-11-13": { "cache_read_input_token_cost": 1.25e-07, @@ -6308,74 +5951,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5-chat": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5-chat-latest": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 6.25e-08 }, "azure/gpt-5-codex": { "cache_read_input_token_cost": 1.25e-07, @@ -6486,7 +6063,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "azure/gpt-5-nano": { "deprecation_date": "2027-02-09", @@ -6559,7 +6137,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 2.5e-09 }, "azure/gpt-5-pro": { "deprecation_date": "2027-04-07", @@ -6636,43 +6215,6 @@ "output_cost_per_token_priority": 2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, @@ -6848,79 +6390,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5.2-chat": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5.2-chat-2025-12-11": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-05-13", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 8.75e-08 }, "azure/gpt-5.2-codex": { "cache_read_input_token_cost": 1.75e-07, @@ -6954,42 +6425,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "azure/gpt-5.3-chat": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "azure/gpt-5.3-codex": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_priority": 3.5e-07, @@ -7313,7 +6748,11 @@ "output_cost_per_token_flex": 7.5e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_above_272k_tokens_batches": 2.5e-07, + "cache_read_input_token_cost_batches": 1.3e-07, + "input_cost_per_token_above_272k_tokens_batches": 2.5e-06, + "output_cost_per_token_above_272k_tokens_batches": 1.125e-05 }, "azure/us/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.75e-07, @@ -7359,7 +6798,11 @@ "output_cost_per_token_batches": 8.25e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_above_272k_tokens_batches": 2.75e-07, + "cache_read_input_token_cost_batches": 1.43e-07, + "input_cost_per_token_above_272k_tokens_batches": 2.75e-06, + "output_cost_per_token_above_272k_tokens_batches": 1.2375e-05 }, "azure/eu/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.75e-07, @@ -7405,7 +6848,11 @@ "output_cost_per_token_batches": 8.25e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_above_272k_tokens_batches": 2.75e-07, + "cache_read_input_token_cost_batches": 1.43e-07, + "input_cost_per_token_above_272k_tokens_batches": 2.75e-06, + "output_cost_per_token_above_272k_tokens_batches": 1.2375e-05 }, "azure/gpt-5.4-pro": { "deprecation_date": "2027-09-07", @@ -7503,7 +6950,9 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "input_cost_per_token_above_272k_tokens_batches": 3e-05, + "output_cost_per_token_above_272k_tokens_batches": 0.000135 }, "azure/gpt-5.6": { "cache_creation_input_token_cost": 6.25e-06, @@ -8926,7 +8375,11 @@ "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", + "cache_read_input_token_cost_above_272k_tokens_batches": 5e-07, + "cache_read_input_token_cost_batches": 2.5e-07, + "input_cost_per_token_above_272k_tokens_batches": 5e-06, + "output_cost_per_token_above_272k_tokens_batches": 2.25e-05 }, "azure/us/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, @@ -9029,7 +8482,11 @@ "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", + "cache_read_input_token_cost_above_272k_tokens_batches": 5.5e-07, + "cache_read_input_token_cost_batches": 2.75e-07, + "input_cost_per_token_above_272k_tokens_batches": 5.5e-06, + "output_cost_per_token_above_272k_tokens_batches": 2.475e-05 }, "azure/eu/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, @@ -9132,7 +8589,11 @@ "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", + "cache_read_input_token_cost_above_272k_tokens_batches": 5.5e-07, + "cache_read_input_token_cost_batches": 2.75e-07, + "input_cost_per_token_above_272k_tokens_batches": 5.5e-06, + "output_cost_per_token_above_272k_tokens_batches": 2.475e-05 }, "azure/gpt-5.5-pro": { "cache_read_input_token_cost": 3e-06, @@ -9319,7 +8780,8 @@ "output_cost_per_token_flex": 2.25e-06, "output_cost_per_token_priority": 9e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 3.75e-08 }, "azure/gpt-5.4-nano": { "deprecation_date": "2027-09-21", @@ -9416,7 +8878,8 @@ "output_cost_per_token_batches": 6.25e-07, "output_cost_per_token_flex": 6.25e-07, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1e-08 }, "azure/gpt-image-1": { "cache_read_input_token_cost": 1.25e-06, @@ -9819,39 +9282,6 @@ "supports_reasoning": true, "supports_vision": false }, - "azure/o1-preview": { - "cache_read_input_token_cost": 7.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_vision": false - }, - "azure/o1-preview-2024-09-12": { - "cache_read_input_token_cost": 7.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_vision": false - }, "azure/o3": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, @@ -10463,7 +9893,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 6.875e-08 }, "azure/us/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, @@ -10502,7 +9933,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.375e-08 }, "azure/us/gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5.5e-09, @@ -10538,7 +9970,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 2.75e-09 }, "azure/us/gpt-5.1": { "deprecation_date": "2027-05-15", @@ -10583,43 +10016,6 @@ "output_cost_per_token_priority": 2.2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.375e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.375e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1.1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.375e-07, @@ -10720,21 +10116,6 @@ "supports_prompt_caching": true, "supports_vision": false }, - "azure/us/o1-preview-2024-09-12": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_vision": false - }, "azure/us/o3-2025-04-16": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, @@ -11236,18 +10617,6 @@ "/v1/images/edits" ] }, - "azure_ai/MAI-Image-2e": { - "deprecation_date": "2026-08-15", - "input_cost_per_token": 5e-06, - "litellm_provider": "azure_ai", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "output_cost_per_image_token": 1.95e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/images/generations" - ] - }, "azure_ai/MAI-Thinking-1": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, @@ -11272,34 +10641,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "azure_ai/Llama-3.2-11B-Vision-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 3.7e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 3.7e-07, - "source": "https://marketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-11b-vision-instruct-offer?tab=Overview", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "azure_ai/Llama-3.2-90B-Vision-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 2.04e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 2.04e-06, - "source": "https://marketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-90b-vision-instruct-offer?tab=Overview", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, "azure_ai/Llama-3.3-70B-Instruct": { "input_cost_per_token": 7.1e-07, "litellm_provider": "azure_ai", @@ -11348,18 +10689,6 @@ "output_cost_per_token": 3.7e-07, "supports_tool_choice": true }, - "azure_ai/Meta-Llama-3.1-405B-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 5.33e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 1.6e-05, - "source": "https://marketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-405b-instruct-offer?tab=PlansAndPrice", - "supports_tool_choice": true - }, "azure_ai/Meta-Llama-3.1-70B-Instruct": { "input_cost_per_token": 2.68e-06, "litellm_provider": "azure_ai", @@ -11371,18 +10700,6 @@ "source": "https://marketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-70b-instruct-offer?tab=PlansAndPrice", "supports_tool_choice": true }, - "azure_ai/Meta-Llama-3.1-8B-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 3e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 6.1e-07, - "source": "https://marketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-8b-instruct-offer?tab=PlansAndPrice", - "supports_tool_choice": true - }, "azure_ai/Phi-3-medium-128k-instruct": { "input_cost_per_token": 1.7e-07, "litellm_provider": "azure_ai", @@ -11553,16 +10870,6 @@ "supports_tool_choice": true, "supports_reasoning": true }, - "azure_ai/mistral-document-ai-2505": { - "deprecation_date": "2026-07-20", - "litellm_provider": "azure_ai", - "ocr_cost_per_page": 0.003, - "mode": "ocr", - "supported_endpoints": [ - "/v1/ocr" - ], - "source": "https://devblogs.microsoft.com/foundry/whats-new-in-azure-ai-foundry-august-2025/#mistral-document-ai-(ocr)-%E2%80%94-serverless-in-foundry" - }, "azure_ai/mistral-document-ai-2512": { "litellm_provider": "azure_ai", "ocr_cost_per_page": 0.003, @@ -11663,17 +10970,6 @@ "mode": "rerank", "output_cost_per_token": 0.0 }, - "azure_ai/cohere-rerank-v3.5": { - "deprecation_date": "2026-05-14", - "input_cost_per_query": 0.002, - "input_cost_per_token": 0.0, - "litellm_provider": "azure_ai", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "rerank", - "output_cost_per_token": 0.0 - }, "azure_ai/cohere-rerank-v4.0-pro": { "input_cost_per_query": 0.0025, "input_cost_per_token": 0.0, @@ -11726,19 +11022,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "azure_ai/deepseek-r1": { - "deprecation_date": "2026-08-13", - "input_cost_per_token": 1.35e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 5.4e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_reasoning": true, - "supports_tool_choice": true - }, "azure_ai/deepseek-v3": { "input_cost_per_token": 1.14e-06, "litellm_provider": "azure_ai", @@ -11750,33 +11033,6 @@ "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true }, - "azure_ai/deepseek-v3-0324": { - "deprecation_date": "2026-07-13", - "input_cost_per_token": 1.14e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4.56e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_tool_choice": true - }, - "azure_ai/deepseek-v3.1": { - "deprecation_date": "2026-07-13", - "input_cost_per_token": 1.23e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 4.94e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "azure_ai/deepseek-v4-pro": { "deprecation_date": "2028-02-20", "input_cost_per_token": 1.74e-06, @@ -11843,68 +11099,6 @@ ], "supports_embedding_image_input": true }, - "azure_ai/global/grok-3": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 3e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/global/grok-3-mini": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.27e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/grok-3": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 3e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/grok-3-mini": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.27e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, "azure_ai/grok-4": { "input_cost_per_token": 3e-06, "litellm_provider": "azure_ai", @@ -11998,36 +11192,6 @@ "supports_vision": true, "supports_web_search": true }, - "azure_ai/grok-4-fast-non-reasoning": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2e-07, - "output_cost_per_token": 5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/grok-4-fast-reasoning": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2e-07, - "output_cost_per_token": 5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "azure_ai/grok-4-1-fast-non-reasoning": { "input_cost_per_token": 2e-07, "output_cost_per_token": 5e-07, @@ -12982,6 +12146,23 @@ "supports_tool_choice": true, "output_cost_per_token": 1.86e-06 }, + "bedrock/eu-west-2/nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 2.3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 256000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.01e-06, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, "bedrock/eu-west-2/qwen.qwen3-coder-next": { "input_cost_per_token": 7.8e-07, "litellm_provider": "bedrock", @@ -13534,40 +12715,6 @@ "mode": "chat", "output_cost_per_token": 1.5e-06 }, - "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, - "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 3e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-08, - "cache_creation_input_token_cost": 3.75e-07 - }, "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -13745,61 +12892,6 @@ "mode": "chat", "output_cost_per_token": 1.5e-06 }, - "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { - "cache_creation_input_token_cost": 4.5e-06, - "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, - "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 3e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-08, - "cache_creation_input_token_cost": 3.75e-07 - }, "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -14237,34 +13329,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "cerebras/zai-glm-4.6": { - "deprecation_date": "2026-01-20", - "input_cost_per_token": 2.25e-06, - "litellm_provider": "cerebras", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-06, - "source": "https://www.cerebras.ai/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, - "cerebras/zai-glm-4.7": { - "deprecation_date": "2026-08-17", - "input_cost_per_token": 2.25e-06, - "litellm_provider": "cerebras", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-06, - "source": "https://www.cerebras.ai/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "cerebras/qwen-3.8-27b": { "input_cost_per_token": 9.9e-07, "litellm_provider": "cerebras", @@ -14290,23 +13354,6 @@ "mode": "chat", "output_cost_per_token": 5e-07 }, - "chatgpt-4o-latest": { - "deprecation_date": "2026-02-17", - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "gpt-4o-transcribe-diarize": { "input_cost_per_audio_token": 2.5e-06, "input_cost_per_token": 2.5e-06, @@ -14369,131 +13416,6 @@ "prompt_cache_min_tokens": 4096, "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, - "claude-3-7-sonnet-20250219": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-02-19", - "input_cost_per_token": 3e-06, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, - "claude-3-haiku-20240307": { - "cache_creation_input_token_cost": 3e-07, - "cache_creation_input_token_cost_above_1hr": 5e-07, - "cache_read_input_token_cost": 3e-08, - "deprecation_date": "2026-04-20", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "claude-3-opus-20240229": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "deprecation_date": "2026-01-05", - "input_cost_per_token": 1.5e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "claude-4-opus-20250514": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_read_input_token_cost": 1.5e-06, - "deprecation_date": "2026-06-15", - "input_cost_per_token": 1.5e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "claude-4-sonnet-20250514": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost": 3e-07, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "deprecation_date": "2026-06-15", - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "prompt_cache_min_tokens": 1024 - }, "claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, @@ -14672,92 +13594,6 @@ "input_cost_per_token_batches": 1.5e-06, "output_cost_per_token_batches": 7.5e-06 }, - "claude-opus-4-1": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_native_structured_output": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024, - "deprecation_date": "2026-08-05" - }, - "claude-opus-4-1-20250805": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "deprecation_date": "2026-08-05", - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_native_structured_output": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "claude-opus-4-20250514": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "deprecation_date": "2026-06-15", - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "claude-opus-4-5-20251101": { "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -15185,38 +14021,6 @@ "prompt_cache_min_tokens": 1024, "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, - "claude-sonnet-4-20250514": { - "deprecation_date": "2026-06-15", - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "cloudflare/@cf/meta/llama-2-7b-chat-fp16": { "input_cost_per_token": 1.923e-06, "litellm_provider": "cloudflare", @@ -15569,36 +14373,6 @@ "supports_assistant_prefill": true, "supports_tool_choice": true }, - "codex-mini-latest": { - "cache_read_input_token_cost": 3.75e-07, - "deprecation_date": "2026-02-12", - "input_cost_per_token": 1.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 6e-06, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "cohere.command-light-text-v14": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", @@ -15609,28 +14383,6 @@ "output_cost_per_token": 6e-07, "supports_tool_choice": true }, - "cohere.command-r-plus-v1:0": { - "deprecation_date": "2026-08-19", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_tool_choice": true - }, - "cohere.command-r-v1:0": { - "deprecation_date": "2026-08-19", - "input_cost_per_token": 5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_tool_choice": true - }, "cohere.command-text-v14": { "input_cost_per_token": 1.5e-06, "litellm_provider": "bedrock", @@ -15720,16 +14472,6 @@ "mode": "rerank", "output_cost_per_token": 0.0 }, - "command": { - "input_cost_per_token": 1e-06, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "completion", - "output_cost_per_token": 2e-06, - "deprecation_date": "2025-09-15" - }, "command-a-03-2025": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", @@ -15756,17 +14498,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "command-light": { - "input_cost_per_token": 3e-07, - "litellm_provider": "cohere_chat", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_tool_choice": true, - "deprecation_date": "2025-09-15" - }, "command-nightly": { "input_cost_per_token": 1e-06, "litellm_provider": "cohere", @@ -15776,18 +14507,6 @@ "mode": "completion", "output_cost_per_token": 2e-06 }, - "command-r": { - "input_cost_per_token": 1.5e-07, - "litellm_provider": "cohere_chat", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_function_calling": true, - "supports_tool_choice": true, - "deprecation_date": "2025-09-15" - }, "command-r-08-2024": { "input_cost_per_token": 1.5e-07, "litellm_provider": "cohere_chat", @@ -15799,18 +14518,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "command-r-plus": { - "input_cost_per_token": 2.5e-06, - "litellm_provider": "cohere_chat", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supports_function_calling": true, - "supports_tool_choice": true, - "deprecation_date": "2025-09-15" - }, "command-r-plus-08-2024": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", @@ -15863,26 +14570,6 @@ "supports_vision": true, "source": "https://platform.openai.com/docs/models/computer-use-preview" }, - "dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_image": 0.02, - "litellm_provider": "openai", - "mode": "image_generation", - "supported_endpoints": [ - "/v1/images/generations", - "/v1/images/edits", - "/v1/images/variations" - ] - }, - "dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_image": 0.04, - "litellm_provider": "openai", - "mode": "image_generation", - "supported_endpoints": [ - "/v1/images/generations" - ] - }, "deepseek-chat": { "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 2.8e-07, @@ -18879,30 +17566,6 @@ "output_vector_size": 1024, "source": "https://www.databricks.com/product/pricing/foundation-model-serving" }, - "databricks/databricks-claude-3-7-sonnet": { - "cache_creation_input_token_cost": 3.74997e-06, - "cache_read_input_token_cost": 3.0002e-07, - "deprecation_date": "2026-04-12", - "input_cost_per_token": 2.9999900000000002e-06, - "input_dbu_cost_per_token": 4.2857e-05, - "litellm_provider": "databricks", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.5000020000000002e-05, - "output_dbu_cost_per_token": 0.000214286, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_anthropic_thinking_payload": true, - "supports_tool_choice": true - }, "databricks/databricks-claude-fable-5": { "cache_creation_input_token_cost": 1.250004e-05, "cache_read_input_token_cost": 1.00002e-06, @@ -19865,44 +18528,6 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, - "databricks/databricks-gpt-5-1-codex-max": { - "cache_creation_input_token_cost": 1.24999e-06, - "cache_read_input_token_cost": 1.2502e-07, - "deprecation_date": "2026-07-16", - "input_cost_per_token": 1.24999e-06, - "input_dbu_cost_per_token": 1.7857e-05, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 9.999990000000002e-06, - "output_dbu_cost_per_token": 0.000142857, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, - "databricks/databricks-gpt-5-1-codex-mini": { - "cache_creation_input_token_cost": 2.4997e-07, - "cache_read_input_token_cost": 2.499e-08, - "deprecation_date": "2026-07-16", - "input_cost_per_token": 2.4997e-07, - "input_dbu_cost_per_token": 3.571e-06, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.99997e-06, - "output_dbu_cost_per_token": 2.8571e-05, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, "databricks/databricks-gpt-5-2": { "cache_creation_input_token_cost": 1.75e-06, "cache_read_input_token_cost": 1.75e-07, @@ -19923,25 +18548,6 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, - "databricks/databricks-gpt-5-2-codex": { - "cache_creation_input_token_cost": 1.75e-06, - "cache_read_input_token_cost": 1.75e-07, - "deprecation_date": "2026-07-16", - "input_cost_per_token": 1.75e-06, - "input_dbu_cost_per_token": 2.5e-05, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_dbu_cost_per_token": 0.0002, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, "databricks/databricks-gpt-5-3-codex": { "cache_creation_input_token_cost": 1.75e-06, "cache_read_input_token_cost": 1.75e-07, @@ -20368,25 +18974,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "databricks/databricks-llama-2-70b-chat": { - "cache_creation_input_token_cost": 5.0001e-07, - "cache_read_input_token_cost": 5.0001e-07, - "deprecation_date": "2024-10-30", - "input_cost_per_token": 5.0001e-07, - "input_dbu_cost_per_token": 7.143e-06, - "litellm_provider": "databricks", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.5000300000000002e-06, - "output_dbu_cost_per_token": 2.1429e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, "databricks/databricks-llama-4-maverick": { "cache_creation_input_token_cost": 5.0001e-07, "cache_read_input_token_cost": 5.0001e-07, @@ -20405,25 +18992,6 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, - "databricks/databricks-meta-llama-3-1-405b-instruct": { - "cache_creation_input_token_cost": 5.00003e-06, - "cache_read_input_token_cost": 5.00003e-06, - "deprecation_date": "2026-02-15", - "input_cost_per_token": 5.00003e-06, - "input_dbu_cost_per_token": 7.1429e-05, - "litellm_provider": "databricks", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.5000020000000002e-05, - "output_dbu_cost_per_token": 0.000214286, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, "databricks/databricks-meta-llama-3-1-8b-instruct": { "cache_creation_input_token_cost": 1.5001e-07, "cache_read_input_token_cost": 1.5001e-07, @@ -20459,82 +19027,6 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, - "databricks/databricks-meta-llama-3-70b-instruct": { - "cache_creation_input_token_cost": 1.00002e-06, - "cache_read_input_token_cost": 1.00002e-06, - "deprecation_date": "2024-07-23", - "input_cost_per_token": 1.00002e-06, - "input_dbu_cost_per_token": 1.4286e-05, - "litellm_provider": "databricks", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 2.9999900000000002e-06, - "output_dbu_cost_per_token": 4.2857e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, - "databricks/databricks-mixtral-8x7b-instruct": { - "cache_creation_input_token_cost": 5.0001e-07, - "cache_read_input_token_cost": 5.0001e-07, - "deprecation_date": "2025-04-30", - "input_cost_per_token": 5.0001e-07, - "input_dbu_cost_per_token": 7.143e-06, - "litellm_provider": "databricks", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.00002e-06, - "output_dbu_cost_per_token": 1.4286e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, - "databricks/databricks-mpt-30b-instruct": { - "cache_creation_input_token_cost": 1.00002e-06, - "cache_read_input_token_cost": 1.00002e-06, - "deprecation_date": "2024-08-30", - "input_cost_per_token": 1.00002e-06, - "input_dbu_cost_per_token": 1.4286e-05, - "litellm_provider": "databricks", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.00002e-06, - "output_dbu_cost_per_token": 1.4286e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, - "databricks/databricks-mpt-7b-instruct": { - "cache_creation_input_token_cost": 5.0001e-07, - "cache_read_input_token_cost": 5.0001e-07, - "deprecation_date": "2024-08-30", - "input_cost_per_token": 5.0001e-07, - "input_dbu_cost_per_token": 7.143e-06, - "litellm_provider": "databricks", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 0.0, - "output_dbu_cost_per_token": 0.0, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, "databricks/databricks-qwen35-122b-a10b": { "cache_creation_input_token_cost": 2.2001e-07, "cache_read_input_token_cost": 2.2001e-07, @@ -21599,18 +20091,6 @@ "supports_tool_choice": true, "supports_function_calling": true }, - "deepinfra/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-06-01", - "max_tokens": 1000000, - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 4e-07, - "litellm_provider": "deepinfra", - "mode": "chat", - "supports_tool_choice": true, - "supports_function_calling": true - }, "deepinfra/google/gemini-2.5-flash": { "max_tokens": 1000000, "max_input_tokens": 1000000, @@ -22469,15 +20949,6 @@ "/v1/audio/speech" ] }, - "embed-english-light-v2.0": { - "deprecation_date": "2026-04-04", - "input_cost_per_token": 1e-07, - "litellm_provider": "cohere", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "embedding", - "output_cost_per_token": 0.0 - }, "embed-english-light-v3.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", @@ -22486,15 +20957,6 @@ "mode": "embedding", "output_cost_per_token": 0.0 }, - "embed-english-v2.0": { - "deprecation_date": "2026-04-04", - "input_cost_per_token": 1e-07, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_tokens": 4096, - "mode": "embedding", - "output_cost_per_token": 0.0 - }, "embed-english-v3.0": { "input_cost_per_image": 0.0001, "input_cost_per_token": 1e-07, @@ -22509,15 +20971,6 @@ "supports_embedding_image_input": true, "supports_image_input": true }, - "embed-multilingual-v2.0": { - "deprecation_date": "2026-04-04", - "input_cost_per_token": 1e-07, - "litellm_provider": "cohere", - "max_input_tokens": 768, - "max_tokens": 768, - "mode": "embedding", - "output_cost_per_token": 0.0 - }, "embed-multilingual-v3.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", @@ -22685,23 +21138,6 @@ "cache_read_input_token_cost": 3e-07, "cache_creation_input_token_cost": 3.75e-06 }, - "eu.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "eu.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", @@ -22717,23 +21153,6 @@ "cache_read_input_token_cost": 1.5e-06, "cache_creation_input_token_cost": 1.875e-05 }, - "eu.anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "eu.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, @@ -25305,26 +23724,6 @@ "supports_tool_choice": true, "supports_vision": false }, - "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { - "cache_read_input_token_cost": 6e-07, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 1.2e-06, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813": { "cache_read_input_token_cost": 4.4e-08, "cache_read_input_token_cost_priority": 5.5e-08, @@ -25712,26 +24111,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { - "cache_read_input_token_cost": 6e-08, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 3e-07, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 196608, - "max_output_tokens": 196608, - "max_tokens": 196608, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/accounts/fireworks/models/minimax-m3": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 9e-08, @@ -25819,26 +24198,6 @@ "supports_tool_choice": true, "supports_vision": false }, - "fireworks_ai/deepseek-v4-pro": { - "cache_read_input_token_cost": 6e-07, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 1.2e-06, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/glm-4p7": { "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 6e-07, @@ -26038,26 +24397,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "fireworks_ai/minimax-m2p7": { - "cache_read_input_token_cost": 6e-08, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 3e-07, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 196608, - "max_output_tokens": 196608, - "max_tokens": 196608, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/minimax-m3": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 9e-08, @@ -26235,31 +24574,6 @@ "comment": "Open flagship GLM for long-horizon coding agents and million-token context work", "source": "https://api.friendli.ai/serverless/v1/models" }, - "friendliai/LGAI-EXAONE/K-EXAONE-2.0-750B-A37B": { - "litellm_provider": "friendliai", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "input_cost_per_token": 6e-07, - "output_cost_per_token": 2.4e-06, - "cache_read_input_token_cost": 1.2e-07, - "supports_prompt_caching": true, - "supports_reasoning": true, - "reasoning_effort_levels": [], - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_native_structured_output": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_image_input": false, - "supports_video_input": false, - "mode": "chat", - "comment": "Frontier-scale multilingual language model developed by LG AI Research", - "deprecation_date": "2026-09-06", - "source": "https://api.friendli.ai/serverless/v1/models" - }, "friendliai/deepseek-ai/DeepSeek-V3.2": { "litellm_provider": "friendliai", "max_input_tokens": 163840, @@ -26565,160 +24879,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "gemini-2.0-flash": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 1e-06, - "input_cost_per_audio_token_batches": 5e-07, - "input_cost_per_character": 3.75e-08, - "input_cost_per_token": 1.5e-07, - "input_cost_per_token_batches": 7.5e-08, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "output_cost_per_token_batches": 3e-07, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini-2.0-flash-001": { - "cache_read_input_token_cost": 3.75e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 1e-06, - "input_cost_per_token": 1.5e-07, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini-2.0-flash-lite": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_audio_token_batches": 3.75e-08, - "input_cost_per_character": 1.875e-08, - "input_cost_per_token": 7.5e-08, - "input_cost_per_token_batches": 3.75e-08, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "output_cost_per_token_batches": 1.5e-07, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini-2.0-flash-lite-001": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_token": 7.5e-08, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini-2.5-flash": { "cache_read_input_audio_token_cost": 1e-07, "deprecation_date": "2026-10-20", @@ -26830,6 +24990,7 @@ "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, "deprecation_date": "2027-05-28", @@ -26878,7 +25039,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_read_input_token_cost_batches": 1e-07 }, "gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -26922,6 +25084,7 @@ }, "gemini-3.1-flash-image": { "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, @@ -26963,7 +25126,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_read_input_token_cost_batches": 2.5e-08 }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -27005,6 +25169,7 @@ }, "gemini-3.1-flash-lite-image": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, @@ -27042,7 +25207,8 @@ "supports_response_schema": false, "supports_system_messages": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -27097,6 +25263,7 @@ "cache_read_input_audio_token_cost": 5e-08, "deprecation_date": "2027-05-07", "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_audio_token": 5e-07, @@ -27151,11 +25318,13 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_audio_token_batches": 2.5e-07 + "input_cost_per_audio_token_batches": 2.5e-07, + "cache_read_input_token_cost_batches": 1.25e-08 }, "gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_token": 3e-07, @@ -27208,10 +25377,12 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 1.5e-08 }, "deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_batches": 1e-07, "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, @@ -27243,7 +25414,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "cache_read_input_token_cost_batches": 1e-07 }, "gemini-2.5-flash-lite": { "cache_read_input_audio_token_cost": 3e-08, @@ -27531,53 +25703,6 @@ }, "gemini_native_audio": true }, - "gemini-2.5-flash-lite-preview-06-17": { - "deprecation_date": "2025-11-18", - "cache_read_input_token_cost": 1e-08, - "input_cost_per_audio_token": 5e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 4e-07, - "output_cost_per_token": 4e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, "gemini-2.5-pro": { "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 1.25e-07, @@ -27636,62 +25761,6 @@ "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 1.8e-05 }, - "gemini-3-pro-preview": { - "deprecation_date": "2026-03-26", - "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, - "input_cost_per_token": 2e-06, - "input_cost_per_token_above_200k_tokens": 4e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_token": 1.2e-05, - "output_cost_per_token_above_200k_tokens": 1.8e-05, - "output_cost_per_token_batches": 6e-06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "supports_native_streaming": true, - "input_cost_per_token_priority": 3.6e-06, - "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, - "output_cost_per_token_priority": 2.16e-05, - "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, - "cache_read_input_token_cost_priority": 3.6e-07, - "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, "gemini-3.1-pro-preview": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-07, @@ -27922,6 +25991,7 @@ "prompt_cache_min_tokens": 4096, "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_batches": 7.5e-08, "input_cost_per_token": 1.5e-06, "input_cost_per_audio_token": 1.5e-06, "litellm_provider": "vertex_ai", @@ -27976,11 +26046,13 @@ "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_batches": 7.5e-08 }, "vertex_ai/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -28033,11 +26105,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "vertex_ai/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -28091,11 +26165,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "vertex_ai/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -28149,7 +26225,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "vertex_ai/gemini-3.1-pro-preview": { "prompt_cache_min_tokens": 4096, @@ -28342,53 +26419,9 @@ "supports_url_context": true, "supports_vision": true }, - "gemini/gemini-robotics-er-1.5-preview": { - "cache_read_input_token_cost": 0, - "deprecation_date": "2026-04-30", - "input_cost_per_token": 3e-07, - "input_cost_per_audio_token": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "output_cost_per_reasoning_token": 2.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-robotics-er-1-5-preview", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions" - ], - "supported_modalities": [ - "text", - "image", - "video", - "audio" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": false, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "rpm": 10, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini/gemini-robotics-er-2-preview": { "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_batches": 5e-08, "input_cost_per_audio_token": 2e-06, "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, @@ -28436,53 +26469,6 @@ "supports_web_search": true, "web_search_billing_unit": "per_query" }, - "gemini/gemini-robotics-er-1.6-preview": { - "deprecation_date": "2026-08-31", - "input_cost_per_audio_token": 2e-06, - "input_cost_per_token": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 131072, - "max_output_tokens": 65536, - "max_tokens": 65536, - "mode": "chat", - "output_cost_per_reasoning_token": 5e-06, - "output_cost_per_token": 5e-06, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-robotics-er", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "web_search_billing_unit": "per_query" - }, "gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -28619,27 +26605,6 @@ "source": "https://ai.google.dev/gemini-api/docs/embeddings#model-versions", "tpm": 10000000 }, - "gemini/gemini-embedding-2-preview": { - "deprecation_date": "2026-08-10", - "input_cost_per_audio_token": 6.5e-06, - "input_cost_per_audio_token_batches": 3.25e-06, - "input_cost_per_image_token": 4.5e-07, - "input_cost_per_image_token_batches": 2.25e-07, - "input_cost_per_token": 2e-07, - "input_cost_per_token_batches": 1e-07, - "input_cost_per_video_token": 1.2e-05, - "input_cost_per_video_token_batches": 6e-06, - "litellm_provider": "gemini", - "max_input_tokens": 8192, - "max_tokens": 8192, - "mode": "embedding", - "output_cost_per_token": 0, - "output_vector_size": 3072, - "rpm": 10000, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supports_multimodal": true, - "tpm": 10000000 - }, "gemini/gemini-embedding-2": { "input_cost_per_audio_token": 6.5e-06, "input_cost_per_audio_token_batches": 3.25e-06, @@ -28662,138 +26627,10 @@ "supports_vision": true, "tpm": 10000000 }, - "gemini/gemini-1.5-flash": { - "deprecation_date": "2025-09-29", - "input_cost_per_token": 7.5e-08, - "input_cost_per_token_above_128k_tokens": 1.5e-07, - "litellm_provider": "gemini", - "max_input_tokens": 8192, - "max_tokens": 8192, - "mode": "embedding", - "output_cost_per_token": 0, - "output_vector_size": 3072, - "rpm": 10000, - "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", - "supports_multimodal": true, - "tpm": 10000000 - }, - "gemini/gemini-2.0-flash": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-07, - "rpm": 10000, - "source": "https://ai.google.dev/pricing#2_0flash", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 10000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini/gemini-2.0-flash-001": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-07, - "rpm": 10000, - "source": "https://ai.google.dev/pricing#2_0flash", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 10000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini/gemini-2.0-flash-lite": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_token": 7.5e-08, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "rpm": 4000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.0-flash-lite", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 4000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini/gemini-2.5-flash": { "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 3e-08, "cache_read_input_token_cost_flex": 3e-08, "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_audio_token": 1e-06, @@ -28952,50 +26789,6 @@ "web_search_billing_unit": "per_query", "supports_reasoning": false }, - "gemini/gemini-3-pro-image-preview": { - "deprecation_date": "2026-06-25", - "input_cost_per_image": 0.0011, - "input_cost_per_token": 2e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 65536, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "image_generation", - "output_cost_per_image": 0.134, - "output_cost_per_image_token": 0.00012, - "output_cost_per_token": 1.2e-05, - "rpm": 1000, - "tpm": 4000000, - "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, "gemini/nano-banana-pro-preview": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -29081,49 +26874,6 @@ }, "web_search_billing_unit": "per_query" }, - "gemini/gemini-3.1-flash-image-preview": { - "deprecation_date": "2026-06-25", - "input_cost_per_token": 5e-07, - "input_cost_per_token_batches": 2.5e-07, - "litellm_provider": "gemini", - "max_input_tokens": 65536, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "image_generation", - "output_cost_per_image": 0.045, - "output_cost_per_image_token": 6e-05, - "output_cost_per_token": 3e-06, - "output_cost_per_token_batches": 1.5e-06, - "rpm": 1000, - "tpm": 4000000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, "gemini/gemini-3.1-flash-lite-image": { "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, @@ -29205,6 +26955,7 @@ "gemini/gemini-2.5-flash-lite": { "cache_read_input_audio_token_cost": 3e-08, "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_batches": 1e-08, "cache_read_input_token_cost_flex": 1e-08, "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_audio_token": 3e-07, @@ -29261,104 +27012,6 @@ "supports_audio_input": true, "supports_image_size": false }, - "gemini/gemini-2.5-flash-lite-preview-09-2025": { - "cache_read_input_token_cost": 1e-08, - "deprecation_date": "2026-03-31", - "input_cost_per_audio_token": 3e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 4e-07, - "output_cost_per_token": 4e-07, - "rpm": 15, - "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, - "gemini/gemini-2.5-flash-preview-09-2025": { - "cache_read_input_token_cost": 3e-08, - "deprecation_date": "2026-02-17", - "input_cost_per_audio_token": 1e-06, - "input_cost_per_token": 3e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 2.5e-06, - "output_cost_per_token": 2.5e-06, - "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, @@ -29476,55 +27129,6 @@ "supports_video_input": true, "web_search_billing_unit": "per_query" }, - "gemini/gemini-2.5-flash-lite-preview-06-17": { - "deprecation_date": "2025-11-18", - "cache_read_input_token_cost": 1e-08, - "input_cost_per_audio_token": 5e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 4e-07, - "output_cost_per_token": 4e-07, - "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, @@ -29550,6 +27154,7 @@ "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_batches": 1.25e-07, "cache_read_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, @@ -29634,117 +27239,10 @@ "supports_vision": true, "tpm": 800000 }, - "gemini/gemini-3-pro-preview": { - "deprecation_date": "2026-03-09", - "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "input_cost_per_token": 2e-06, - "input_cost_per_token_above_200k_tokens": 4e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_token": 1.2e-05, - "output_cost_per_token_above_200k_tokens": 1.8e-05, - "output_cost_per_token_batches": 6e-06, - "rpm": 2000, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 800000, - "input_cost_per_token_priority": 3.6e-06, - "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, - "output_cost_per_token_priority": 2.16e-05, - "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, - "cache_read_input_token_cost_priority": 3.6e-07, - "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, - "gemini/gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-05-25", - "input_cost_per_audio_token": 5e-07, - "input_cost_per_token": 2.5e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65536, - "max_tokens": 65536, - "mode": "chat", - "output_cost_per_reasoning_token": 1.5e-06, - "output_cost_per_token": 1.5e-06, - "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/models", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "supports_native_streaming": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 - }, "gemini/gemini-3.1-flash-lite": { "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2027-05-07", @@ -29806,6 +27304,7 @@ }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 2e-08, "cache_read_input_token_cost_flex": 2e-08, "cache_read_input_token_cost_priority": 5e-08, "input_cost_per_token": 3e-07, @@ -29865,6 +27364,7 @@ "gemini/gemini-3-flash-preview": { "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_batches": 5e-08, "cache_read_input_token_cost_flex": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, @@ -29926,6 +27426,7 @@ "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_batches": 7.5e-08, "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "gemini", @@ -29987,6 +27488,7 @@ "gemini/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30046,6 +27548,7 @@ "gemini/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30106,6 +27609,7 @@ "gemini/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30248,6 +27752,7 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, @@ -30310,6 +27815,7 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, @@ -30414,6 +27920,7 @@ "prompt_cache_min_tokens": 4096, "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_batches": 7.5e-08, "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai-language-models", @@ -30468,11 +27975,13 @@ "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_batches": 7.5e-08 }, "gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30525,11 +28034,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30583,11 +28094,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30641,7 +28154,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -30825,34 +28339,6 @@ "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, - "gemini/imagen-3.0-generate-002": { - "deprecation_date": "2025-11-10", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "gemini/imagen-4.0-fast-generate-001": { - "deprecation_date": "2026-08-17", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "gemini/imagen-4.0-generate-001": { - "deprecation_date": "2026-08-17", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "gemini/imagen-4.0-ultra-generate-001": { - "deprecation_date": "2026-08-17", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, "gemini/learnlm-1.5-pro-experimental": { "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, @@ -30931,21 +28417,6 @@ "supports_web_search": false, "output_cost_per_image": 0.08 }, - "gemini/veo-2.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "gemini", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.35, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, "gemini/veo-3.1-fast-generate-preview": { "litellm_provider": "gemini", "max_input_tokens": 1024, @@ -31805,10 +29276,21 @@ "output_cost_per_token": 3.15e-06 }, "baseten/zai-org/GLM-4.7": { + "cache_read_input_token_cost": 1.2e-07, "input_cost_per_token": 6e-07, "litellm_provider": "baseten", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, "mode": "chat", - "output_cost_per_token": 2.2e-06 + "output_cost_per_token": 2.2e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false }, "baseten/zai-org/GLM-4.6": { "input_cost_per_token": 6e-07, @@ -31835,10 +29317,21 @@ "output_cost_per_token": 2.5e-06 }, "baseten/openai/gpt-oss-120b": { + "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-07, "litellm_provider": "baseten", + "max_input_tokens": 128072, + "max_output_tokens": 128072, + "max_tokens": 128072, "mode": "chat", - "output_cost_per_token": 5e-07 + "output_cost_per_token": 5e-07, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false }, "baseten/deepseek-ai/DeepSeek-V3.1": { "input_cost_per_token": 5e-07, @@ -32133,33 +29626,6 @@ "supports_system_messages": true, "supports_tool_choice": true }, - "gpt-4-0125-preview": { - "deprecation_date": "2026-03-26", - "input_cost_per_token": 1e-05, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 3e-05, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, - "gpt-4-0314": { - "deprecation_date": "2026-03-26", - "input_cost_per_token": 3e-05, - "litellm_provider": "openai", - "max_input_tokens": 8192, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 6e-05, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4-0613": { "deprecation_date": "2026-10-23", "input_cost_per_token": 3e-05, @@ -32229,22 +29695,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "gpt-4-turbo-preview": { - "deprecation_date": "2026-03-26", - "input_cost_per_token": 1e-05, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 3e-05, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4.1": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 8.75e-07, @@ -32587,24 +30037,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "gpt-4o-audio-preview": { - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 2.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 1e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-audio-preview-2024-12-17": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 4e-05, @@ -32789,44 +30221,6 @@ "supports_tool_choice": true, "supports_vision": false }, - "gpt-audio-mini-2025-10-06": { - "deprecation_date": "2026-07-23", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 2.4e-06, - "source": "https://developers.openai.com/api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses", - "/v1/realtime", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "audio" - ], - "supported_output_modalities": [ - "text", - "audio" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": false, - "supports_reasoning": false, - "supports_response_schema": false, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": false - }, "gpt-audio-mini-2025-12-15": { "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, @@ -32923,24 +30317,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "gpt-4o-mini-audio-preview": { - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_token": 1.5e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 6e-07, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-mini-audio-preview-2024-12-17": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1e-05, @@ -32959,26 +30335,6 @@ "supports_system_messages": true, "supports_tool_choice": true }, - "gpt-4o-mini-realtime-preview": { - "cache_creation_input_audio_token_cost": 3e-07, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 2.4e-06, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-mini-realtime-preview-2024-12-17": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 3e-07, @@ -33025,32 +30381,6 @@ "supports_vision": true, "supports_web_search": true }, - "gpt-4o-mini-search-preview-2025-03-11": { - "cache_read_input_token_cost": 7.5e-08, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.5e-07, - "input_cost_per_token_batches": 7.5e-08, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 6e-07, - "output_cost_per_token_batches": 3e-07, - "search_context_cost_per_query": { - "search_context_size_high": 0.025, - "search_context_size_low": 0.025, - "search_context_size_medium": 0.025 - }, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "gpt-4o-mini-transcribe": { "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, @@ -33085,63 +30415,6 @@ "audio" ] }, - "gpt-4o-realtime-preview": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 2e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, - "gpt-4o-realtime-preview-2024-12-17": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 2e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, - "gpt-4o-realtime-preview-2025-06-03": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 2e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-search-preview": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, @@ -33168,32 +30441,6 @@ "supports_vision": true, "supports_web_search": true }, - "gpt-4o-search-preview-2025-03-11": { - "cache_read_input_token_cost": 1.25e-06, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2.5e-06, - "input_cost_per_token_batches": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "output_cost_per_token_batches": 5e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.025, - "search_context_size_low": 0.025, - "search_context_size_medium": 0.025 - }, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "gpt-4o-transcribe": { "input_cost_per_audio_token": 2.5e-06, "input_cost_per_token": 2.5e-06, @@ -33856,52 +31103,6 @@ "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": false }, - "gpt-5.1-chat-latest": { - "cache_read_input_token_cost": 1.25e-07, - "cache_read_input_token_cost_priority": 2.5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "input_cost_per_token_priority": 2.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "output_cost_per_token_priority": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_native_streaming": true, - "supports_parallel_function_calling": false, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": false, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, "gpt-5.2": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_batches": 8.75e-08, @@ -34008,94 +31209,6 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, - "gpt-5.2-chat-latest": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-08-10", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.3-chat-latest": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-08-10", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, "gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, "input_cost_per_token_batches": 1.05e-05, @@ -34256,6 +31369,158 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, + "cache_creation_input_token_cost_flex": 1.25e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 8e-07, + "cache_read_input_token_cost_batches": 1e-07, + "cache_read_input_token_cost_above_272k_tokens_batches": 2e-07, + "cache_creation_input_token_cost_batches": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens_batches": 2.5e-06, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "input_cost_per_token_above_272k_tokens_flex": 2e-06, + "input_cost_per_token_above_272k_tokens_priority": 8e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_above_272k_tokens_batches": 2e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 4e-06, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "output_cost_per_token_above_272k_tokens_flex": 7.5e-06, + "output_cost_per_token_above_272k_tokens_priority": 3e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_above_272k_tokens_batches": 7.5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "source": "https://developers.openai.com/api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, + "gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens_flex": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 5e-07, + "cache_creation_input_token_cost_flex": 6.25e-08, + "cache_creation_input_token_cost_priority": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "cache_read_input_token_cost_above_272k_tokens_flex": 1e-08, + "cache_read_input_token_cost_above_272k_tokens_priority": 4e-08, + "cache_read_input_token_cost_batches": 5e-09, + "cache_read_input_token_cost_above_272k_tokens_batches": 1e-08, + "cache_creation_input_token_cost_batches": 6.25e-08, + "cache_creation_input_token_cost_above_272k_tokens_batches": 1.25e-07, + "cache_read_input_token_cost_flex": 5e-09, + "cache_read_input_token_cost_priority": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "input_cost_per_token_above_272k_tokens_flex": 1e-07, + "input_cost_per_token_above_272k_tokens_priority": 4e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_above_272k_tokens_batches": 1e-07, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 2e-07, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, + "output_cost_per_token_above_272k_tokens_flex": 3.75e-07, + "output_cost_per_token_above_272k_tokens_priority": 1.5e-06, + "output_cost_per_token_batches": 2.5e-07, + "output_cost_per_token_above_272k_tokens_batches": 3.75e-07, + "output_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_priority": 1e-06, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "source": "https://developers.openai.com/api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "gpt-5.6": { "cache_creation_input_token_cost": 5e-06, "cache_creation_input_token_cost_above_272k_tokens": 1e-05, @@ -35522,6 +32787,7 @@ }, "gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_batches": 6.25e-08, "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "deprecation_date": "2026-12-11", @@ -35569,7 +32835,8 @@ "supports_web_search": true, "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 6.25e-08 }, "gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -35606,251 +32873,6 @@ "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": true }, - "gpt-5-chat-latest": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": false, - "supports_native_streaming": true, - "supports_parallel_function_calling": false, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": false, - "supports_vision": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5-codex": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.1-codex": { - "cache_read_input_token_cost": 1.25e-07, - "cache_read_input_token_cost_priority": 2.5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "input_cost_per_token_priority": 2.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1e-05, - "output_cost_per_token_priority": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.1-codex-max": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.1-codex-mini": { - "cache_read_input_token_cost": 2.5e-08, - "cache_read_input_token_cost_priority": 4.5e-08, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2.5e-07, - "input_cost_per_token_priority": 4.5e-07, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 2e-06, - "output_cost_per_token_priority": 3.6e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.2-codex": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true - }, "gpt-5.3-codex": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_priority": 3.5e-07, @@ -35947,6 +32969,7 @@ }, "gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2026-12-11", @@ -35994,7 +33017,8 @@ "supports_web_search": true, "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "gpt-5-nano": { "cache_read_input_token_cost": 5e-09, @@ -36047,6 +33071,7 @@ }, "gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_batches": 2.5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "deprecation_date": "2026-12-11", "input_cost_per_token": 5e-08, @@ -36092,7 +33117,8 @@ "supports_web_search": true, "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 2.5e-09 }, "gpt-image-1": { "cache_read_input_token_cost": 1.25e-06, @@ -36689,44 +33715,14 @@ "supports_response_schema": true, "supports_vision": true }, - "groq/llama-3.1-8b-instant": { - "deprecation_date": "2026-08-16", - "input_cost_per_token": 5e-08, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 8e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.3-70b-versatile": { - "deprecation_date": "2026-08-16", - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 7.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/gemma-7b-it": { - "deprecation_date": "2024-12-18", - "input_cost_per_token": 5e-08, + "groq/llama-guard-3-8b": { + "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 8192, - "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 8e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true + "output_cost_per_token": 2e-07, + "source": "https://console.groq.com/docs/model/llama-guard-3-8b" }, "groq/meta-llama/llama-prompt-guard-2-22m": { "input_cost_per_token": 3e-08, @@ -36748,58 +33744,6 @@ "output_cost_per_token": 4e-08, "source": "https://console.groq.com/docs/model/meta-llama/llama-prompt-guard-2-86m" }, - "groq/meta-llama/llama-guard-4-12b": { - "deprecation_date": "2026-03-05", - "input_cost_per_token": 2e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-07 - }, - "groq/meta-llama/llama-4-maverick-17b-128e-instruct": { - "deprecation_date": "2026-03-09", - "input_cost_per_token": 2e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/meta-llama/llama-4-scout-17b-16e-instruct": { - "deprecation_date": "2026-07-17", - "input_cost_per_token": 1.1e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3.4e-07, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/moonshotai/kimi-k2-instruct-0905": { - "deprecation_date": "2026-04-15", - "input_cost_per_token": 1e-06, - "output_cost_per_token": 3e-06, - "cache_read_input_token_cost": 5e-07, - "litellm_provider": "groq", - "max_input_tokens": 262144, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "groq/openai/gpt-oss-120b": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.5e-07, @@ -36880,45 +33824,6 @@ "mode": "audio_speech", "source": "https://console.groq.com/docs/models" }, - "groq/playai-tts": { - "deprecation_date": "2025-12-31", - "input_cost_per_character": 5e-05, - "litellm_provider": "groq", - "max_input_tokens": 10000, - "max_output_tokens": 10000, - "max_tokens": 10000, - "mode": "audio_speech" - }, - "groq/qwen/qwen3.6-27b": { - "input_cost_per_token": 6e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://console.groq.com/docs/model/qwen/qwen3.6-27b", - "deprecation_date": "2026-09-14", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/qwen/qwen3-32b": { - "deprecation_date": "2026-07-17", - "input_cost_per_token": 2.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 131000, - "max_output_tokens": 131000, - "max_tokens": 131000, - "mode": "chat", - "output_cost_per_token": 5.9e-07, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, "groq/whisper-large-v3": { "input_cost_per_second": 3.083e-05, "litellm_provider": "groq", @@ -36931,27 +33836,6 @@ "mode": "audio_transcription", "output_cost_per_second": 0.0 }, - "hd/1024-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 7.629e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "hd/1024-x-1792/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 6.539e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "hd/1792-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 6.539e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "heroku/claude-3-5-haiku": { "litellm_provider": "heroku", "max_tokens": 8192, @@ -38778,19 +35662,6 @@ "supports_system_messages": true, "supports_native_structured_output": true }, - "mistral/codestral-2405": { - "deprecation_date": "2025-06-16", - "input_cost_per_token": 1e-06, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 3e-06, - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/codestral-2508": { "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-07, @@ -38834,51 +35705,6 @@ "supports_assistant_prefill": true, "supports_tool_choice": true }, - "mistral/devstral-medium-2507": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/devstral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/devstral-small-2505": { - "deprecation_date": "2025-11-30", - "input_cost_per_token": 1e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://mistral.ai/news/devstral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/devstral-small-2507": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 1e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://mistral.ai/news/devstral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/devstral-small-latest": { "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, @@ -38894,21 +35720,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/labs-devstral-small-2512": { - "deprecation_date": "2026-03-31", - "input_cost_per_token": 1e-07, - "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://docs.mistral.ai/models/devstral-small-2-25-12", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/devstral-latest": { "cache_read_input_token_cost": 4e-08, "input_cost_per_token": 4e-07, @@ -38939,21 +35750,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/devstral-2512": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/devstral-2-vibe-cli", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/ministral-14b-2512": { "input_cost_per_token": 2e-07, "litellm_provider": "mistral", @@ -39229,54 +36025,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/magistral-medium-2506": { - "deprecation_date": "2025-11-30", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://mistral.ai/news/magistral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/magistral-medium-2509": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://mistral.ai/news/magistral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/magistral-medium-1-2-2509": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://mistral.ai/news/magistral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.004, @@ -39316,20 +36064,6 @@ "/v1/batch" ] }, - "mistral/mistral-ocr-2505-completion": { - "deprecation_date": "2026-05-31", - "litellm_provider": "mistral", - "ocr_cost_per_page": 0.001, - "ocr_cost_per_page_batches": 0.0005, - "annotation_cost_per_page": 0.003, - "annotation_cost_per_page_batches": 0.0015, - "mode": "ocr", - "supported_endpoints": [ - "/v1/ocr", - "/v1/batch" - ], - "source": "https://mistral.ai/pricing#api-pricing" - }, "mistral/mistral-ocr-2512": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, @@ -39360,22 +36094,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/magistral-small-2506": { - "deprecation_date": "2025-11-30", - "input_cost_per_token": 5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://mistral.ai/pricing#api-pricing", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/magistral-small-latest": { "cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 1.5e-07, @@ -39393,22 +36111,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/magistral-small-1-2-2509": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://mistral.ai/pricing#api-pricing", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/mistral-embed": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", @@ -39432,48 +36134,6 @@ "max_tokens": 8192, "mode": "embedding" }, - "mistral/mistral-large-2402": { - "deprecation_date": "2025-06-16", - "input_cost_per_token": 4e-06, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 1.2e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-large-2407": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 9e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-large-2411": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/mistral-large-latest": { "cache_read_input_token_cost": 5e-08, "input_cost_per_token": 5e-07, @@ -39543,49 +36203,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/mistral-medium-2312": { - "deprecation_date": "2025-06-16", - "input_cost_per_token": 2.7e-06, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 8.1e-06, - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-medium-2505": { - "deprecation_date": "2026-08-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 2e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-medium-2508": { - "deprecation_date": "2026-08-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/mistral-medium-3", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/mistral-medium-2604": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.5e-06, @@ -39628,22 +36245,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/mistral-medium-3-1-2508": { - "deprecation_date": "2026-08-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/mistral-medium-3", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/mistral-medium-3-5": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.5e-06, @@ -39700,22 +36301,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/mistral-small-3-2-2506": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 6e-08, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://mistral.ai/pricing", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/ministral-3-3b-2512": { "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, @@ -39809,32 +36394,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/open-codestral-mamba": { - "deprecation_date": "2025-06-06", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-07, - "source": "https://mistral.ai/technology/", - "supports_assistant_prefill": true, - "supports_tool_choice": true - }, - "mistral/open-mistral-7b": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 2.5e-07, - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/open-mistral-nemo": { "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-07, @@ -39849,78 +36408,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/open-mistral-nemo-2407": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 3e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://mistral.ai/technology/", - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/open-mixtral-8x22b": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 65336, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 6e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/open-mixtral-8x7b": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 7e-07, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 7e-07, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/pixtral-12b-2409": { - "deprecation_date": "2025-12-31", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1.5e-07, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "mistral/pixtral-large-2411": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/pixtral-large-latest": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, @@ -39969,36 +36456,6 @@ "supports_audio_input": false, "supports_response_schema": true }, - "moonshot/kimi-k2-0711-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_web_search": true - }, - "moonshot/kimi-k2-0905-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "moonshot/kimi-k2.7-code": { "cache_read_input_token_cost": 1.9e-07, "input_cost_per_token": 9.5e-07, @@ -40017,21 +36474,6 @@ "supports_video_input": true, "supports_vision": true }, - "moonshot/kimi-k2-turbo-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 1.15e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 8e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "moonshot/kimi-k2.5": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 6e-07, @@ -40088,111 +36530,6 @@ "supports_video_input": true, "supports_vision": true }, - "moonshot/kimi-latest": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 2e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-latest-128k": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 2e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-latest-32k": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 1e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-latest-8k": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 2e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-thinking-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2025-11-11", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_vision": true - }, - "moonshot/kimi-k2-thinking": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "supports_web_search": true - }, - "moonshot/kimi-k2-thinking-turbo": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 1.15e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 8e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "moonshot/moonshot-v1-128k": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", @@ -40206,19 +36543,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "moonshot/moonshot-v1-128k-0430": { - "deprecation_date": "2024-04-30", - "input_cost_per_token": 2e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true - }, "moonshot/moonshot-v1-128k-vision-preview": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", @@ -40246,19 +36570,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "moonshot/moonshot-v1-32k-0430": { - "deprecation_date": "2024-04-30", - "input_cost_per_token": 1e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true - }, "moonshot/moonshot-v1-32k-vision-preview": { "input_cost_per_token": 1e-06, "litellm_provider": "moonshot", @@ -40286,19 +36597,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "moonshot/moonshot-v1-8k-0430": { - "deprecation_date": "2024-04-30", - "input_cost_per_token": 2e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true - }, "moonshot/moonshot-v1-8k-vision-preview": { "input_cost_per_token": 2e-07, "litellm_provider": "moonshot", @@ -41489,88 +37787,6 @@ "supports_vision": true, "supports_web_search": true }, - "o3-deep-research": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1e-05, - "input_cost_per_token_batches": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 4e-05, - "output_cost_per_token_batches": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, - "o3-deep-research-2025-06-26": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1e-05, - "input_cost_per_token_batches": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 4e-05, - "output_cost_per_token_batches": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, "o3-mini": { "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-10-23", @@ -41756,88 +37972,6 @@ "supports_vision": true, "supports_web_search": true }, - "o4-mini-deep-research": { - "cache_read_input_token_cost": 5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 8e-06, - "output_cost_per_token_batches": 4e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, - "o4-mini-deep-research-2025-06-26": { - "cache_read_input_token_cost": 5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 8e-06, - "output_cost_per_token_batches": 4e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, "oci/meta.llama-3.1-8b-instruct": { "input_cost_per_token": 7.2e-07, "litellm_provider": "oci", @@ -42810,32 +38944,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "openrouter/anthropic/claude-opus-4": { - "input_cost_per_image": 0.0048, - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "openrouter", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_pdf_input": true, - "supports_response_schema": false, - "supports_web_search": true - }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 1.875e-05, @@ -43089,6 +39197,28 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "openrouter/anthropic/claude-opus-5.5": { + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "openrouter/bytedance/ui-tars-1.5-7b": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-07, @@ -43260,21 +39390,21 @@ "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-pro": { - "input_cost_per_token": 9.31074e-07, + "input_cost_per_token": 8.92272e-07, "input_cost_per_token_cache_hit": 4.4e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "output_cost_per_token": 1.862148e-06, + "output_cost_per_token": 1.784544e-06, "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 7.75895e-08, + "cache_read_input_token_cost": 7.4356e-08, "supports_audio_input": false, "supports_pdf_input": false, "supports_vision": false, @@ -43323,23 +39453,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-07, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "openrouter/google/gemini-2.5-flash": { "cache_creation_input_token_cost": 8.33333333333333e-08, "cache_read_input_audio_token_cost": 1e-07, @@ -43763,27 +39876,6 @@ "supports_vision": true, "supports_web_search": false }, - "openrouter/mistralai/mistral-large-2512": { - "cache_read_input_token_cost": 5.5e-08, - "input_cost_per_image": 0, - "input_cost_per_token": 5.5e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 209715, - "max_tokens": 209715, - "mode": "chat", - "output_cost_per_token": 1.65e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/mistralai/mistral-7b-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "openrouter", @@ -46534,17 +42626,6 @@ "supports_reasoning": true, "supports_system_messages": true }, - "rerank-english-v2.0": { - "input_cost_per_query": 0.002, - "input_cost_per_token": 0.0, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "rerank", - "output_cost_per_token": 0.0, - "deprecation_date": "2025-04-30" - }, "rerank-english-v3.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, @@ -46555,17 +42636,6 @@ "mode": "rerank", "output_cost_per_token": 0.0 }, - "rerank-multilingual-v2.0": { - "input_cost_per_query": 0.002, - "input_cost_per_token": 0.0, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "rerank", - "output_cost_per_token": 0.0, - "deprecation_date": "2025-04-30" - }, "rerank-multilingual-v3.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, @@ -46706,31 +42776,6 @@ "output_cost_per_token": 7e-06, "source": "https://cloud.sambanova.ai/plans/pricing" }, - "sambanova/DeepSeek-R1-Distill-Llama-70B": { - "deprecation_date": "2026-03-20", - "input_cost_per_token": 7e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.4e-06, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/DeepSeek-V3-0324": { - "deprecation_date": "2026-04-14", - "input_cost_per_token": 3e-06, - "litellm_provider": "sambanova", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 4.5e-06, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "sambanova/Llama-4-Maverick-17B-128E-Instruct": { "input_cost_per_token": 6.3e-07, "litellm_provider": "sambanova", @@ -46748,73 +42793,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "sambanova/Llama-4-Scout-17B-16E-Instruct": { - "deprecation_date": "2025-06-19", - "input_cost_per_token": 4e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "metadata": { - "notes": "For vision models, images are converted to 6432 input tokens and are billed at that amount" - }, - "mode": "chat", - "output_cost_per_token": 7e-07, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "sambanova/Meta-Llama-3.1-405B-Instruct": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 5e-06, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "sambanova/Meta-Llama-3.1-8B-Instruct": { - "deprecation_date": "2026-04-14", - "input_cost_per_token": 1e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "sambanova/Meta-Llama-3.2-1B-Instruct": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 4e-08, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 8e-08, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/Meta-Llama-3.2-3B-Instruct": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 8e-08, - "litellm_provider": "sambanova", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.6e-07, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, "sambanova/Meta-Llama-3.3-70B-Instruct": { "input_cost_per_token": 6e-07, "litellm_provider": "sambanova", @@ -46828,54 +42806,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "sambanova/Meta-Llama-Guard-3-8B": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 3e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/QwQ-32B": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 5e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-06, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/Qwen2-Audio-7B-Instruct": { - "deprecation_date": "2025-06-19", - "input_cost_per_token": 5e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 0.0001, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_audio_input": true - }, - "sambanova/Qwen3-32B": { - "deprecation_date": "2026-04-06", - "input_cost_per_token": 4e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "sambanova/DeepSeek-V3.1": { "max_tokens": 131072, "max_input_tokens": 131072, @@ -47468,27 +43398,6 @@ "mode": "image_generation", "output_cost_per_image": 0.14 }, - "standard/1024-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 3.81469e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "standard/1024-x-1792/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 4.359e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "standard/1792-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 4.359e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "linkup/search": { "input_cost_per_query": 0.00587, "litellm_provider": "linkup", @@ -47623,36 +43532,6 @@ "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, - "text-moderation-007": { - "deprecation_date": "2025-10-27", - "input_cost_per_token": 0.0, - "litellm_provider": "openai", - "max_input_tokens": 32768, - "max_output_tokens": 0, - "max_tokens": 0, - "mode": "moderation", - "output_cost_per_token": 0.0 - }, - "text-moderation-latest": { - "deprecation_date": "2025-10-27", - "input_cost_per_token": 0.0, - "litellm_provider": "openai", - "max_input_tokens": 32768, - "max_output_tokens": 0, - "max_tokens": 0, - "mode": "moderation", - "output_cost_per_token": 0.0 - }, - "text-moderation-stable": { - "deprecation_date": "2025-10-27", - "input_cost_per_token": 0.0, - "litellm_provider": "openai", - "max_input_tokens": 32768, - "max_output_tokens": 0, - "max_tokens": 0, - "mode": "moderation", - "output_cost_per_token": 0.0 - }, "text-multilingual-embedding-002": { "deprecation_date": "2027-04-01", "input_cost_per_character": 2.5e-08, @@ -47750,19 +43629,6 @@ "mode": "chat", "output_cost_per_token": 1e-07 }, - "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { - "deprecation_date": "2026-02-06", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "input_cost_per_token": 1.2e-06, - "output_cost_per_token": 1.2e-06, - "max_input_tokens": 131072, - "source": "https://api.together.ai/v1/models" - }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { "litellm_provider": "together_ai", "mode": "chat", @@ -47775,87 +43641,6 @@ "max_input_tokens": 32768, "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { - "deprecation_date": "2026-07-10", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "source": "https://www.together.ai/models/qwen3-235b-a22b-instruct-2507-fp8", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": { - "deprecation_date": "2026-04-16", - "input_cost_per_token": 6.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://www.together.ai/models/qwen3-235b-a22b-thinking-2507", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": { - "deprecation_date": "2026-02-06", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://www.together.ai/models/qwen3-235b-a22b-fp8-tput", - "supports_function_calling": false, - "supports_parallel_function_calling": false, - "supports_tool_choice": false - }, - "together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { - "deprecation_date": "2026-06-04", - "input_cost_per_token": 2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/deepseek-ai/DeepSeek-R1": { - "deprecation_date": "2026-05-14", - "input_cost_per_token": 3e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 128000, - "max_output_tokens": 20480, - "max_tokens": 20480, - "metadata": { - "successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro-0813" - }, - "mode": "chat", - "output_cost_per_token": 7e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/deepseek-ai/DeepSeek-R1-0528-tput": { - "deprecation_date": "2026-02-03", - "input_cost_per_token": 5.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.19e-06, - "source": "https://www.together.ai/models/deepseek-r1-0528-throughput", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/deepseek-ai/DeepSeek-V3": { "input_cost_per_token": 1.25e-06, "litellm_provider": "together_ai", @@ -47872,33 +43657,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/deepseek-ai/DeepSeek-V3.1": { - "deprecation_date": "2026-05-14", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_tokens": 16384, - "metadata": { - "successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro-0813" - }, - "mode": "chat", - "output_cost_per_token": 1.7e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "max_input_tokens": 131072, - "max_output_tokens": 16384 - }, - "together_ai/meta-llama/Llama-3.2-3B-Instruct-Turbo": { - "deprecation_date": "2026-03-06", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": { "input_cost_per_token": 1.04e-06, "litellm_provider": "together_ai", @@ -47912,112 +43670,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 0, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 0, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { - "deprecation_date": "2026-03-31", - "input_cost_per_token": 2.7e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.5e-07, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": { - "deprecation_date": "2026-02-06", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 5.9e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { - "deprecation_date": "2026-02-06", - "input_cost_per_token": 3.5e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 3.5e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { - "deprecation_date": "2026-02-25", - "input_cost_per_token": 8.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.8e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": { - "deprecation_date": "2026-03-06", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/mistralai/Mistral-7B-Instruct-v0.1": { - "deprecation_date": "2025-11-13", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "input_cost_per_token": 2e-07, - "output_cost_per_token": 2e-07, - "max_input_tokens": 32768, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/mistralai/Mistral-Small-24B-Instruct-2501": { - "deprecation_date": "2026-04-02", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 3e-07, - "max_input_tokens": 32768, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": { - "deprecation_date": "2026-04-16", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/moonshotai/Kimi-K2-Instruct": { "input_cost_per_token": 1e-06, "litellm_provider": "together_ai", @@ -48046,19 +43698,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/openai/gpt-oss-20b": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 5e-08, - "litellm_provider": "together_ai", - "max_input_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/togethercomputer/CodeLlama-34b-Instruct": { "litellm_provider": "together_ai", "mode": "chat", @@ -48066,19 +43705,6 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true }, - "together_ai/zai-org/GLM-4.5-Air-FP8": { - "deprecation_date": "2026-04-02", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.1e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/zai-org/GLM-4.6": { "input_cost_per_token": 6e-07, "litellm_provider": "together_ai", @@ -48095,102 +43721,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "together_ai/zai-org/GLM-4.7": { - "deprecation_date": "2026-04-02", - "input_cost_per_token": 4.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 202752, - "max_tokens": 202752, - "metadata": { - "successor": "together_ai/zai-org/GLM-5.2" - }, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, - "together_ai/moonshotai/Kimi-K2.5": { - "deprecation_date": "2026-05-21", - "input_cost_per_token": 5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 256000, - "max_tokens": 256000, - "metadata": { - "successor": "together_ai/moonshotai/Kimi-K3" - }, - "mode": "chat", - "output_cost_per_token": 2.8e-06, - "source": "https://www.together.ai/models/kimi-k2-5", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_reasoning": true - }, - "together_ai/moonshotai/Kimi-K2-Instruct-0905": { - "deprecation_date": "2026-03-06", - "input_cost_per_token": 1e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "metadata": { - "successor": "together_ai/moonshotai/Kimi-K3" - }, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://www.together.ai/models/kimi-k2-0905", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct": { - "deprecation_date": "2026-04-02", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "metadata": { - "successor": "together_ai/Qwen/Qwen3.7-Plus" - }, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { - "deprecation_date": "2026-02-25", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "metadata": { - "successor": "together_ai/Qwen/Qwen3.6-Plus" - }, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3.5-397B-A17B": { - "cache_read_input_token_cost": 3.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 3.6e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/MiniMaxAI/MiniMax-M3": { "cache_read_input_token_cost": 6e-08, "input_cost_per_token": 3e-07, @@ -48312,23 +43842,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/deepseek-ai/DeepSeek-V4-Pro": { - "deprecation_date": "2026-08-27", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.74e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 512000, - "max_tokens": 512000, - "mode": "chat", - "output_cost_per_token": 3.48e-06, - "source": "https://docs.together.ai/docs/serverless-models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/deepseek-ai/DeepSeek-V4-Pro-0813": { "cache_read_input_token_cost": 1.3e-07, "deprecation_date": "2026-09-29", @@ -48345,52 +43858,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/google/gemma-3n-E4B-it": { - "deprecation_date": "2026-08-25", - "input_cost_per_token": 6e-08, - "litellm_provider": "together_ai", - "max_input_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 1.2e-07, - "source": "https://docs.together.ai/docs/serverless-models" - }, - "together_ai/google/gemma-4-31B-it": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 3.9e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 9.7e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "together_ai/intfloat/multilingual-e5-large-instruct": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 2e-08, - "litellm_provider": "together_ai", - "max_input_tokens": 514, - "max_tokens": 514, - "mode": "embedding", - "output_cost_per_token": 2e-08, - "output_vector_size": 1024, - "source": "https://docs.together.ai/docs/serverless-models" - }, - "together_ai/meta-llama/Llama-Guard-4-12B": { - "deprecation_date": "2026-08-25", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 1048576, - "max_tokens": 1048576, - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://docs.together.ai/docs/serverless-models" - }, "together_ai/meta-models/Muse-Glimmer-30B": { "cache_read_input_token_cost": 4e-08, "input_cost_per_token": 3.5e-07, @@ -48402,23 +43869,6 @@ "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, - "together_ai/moonshotai/Kimi-K2.7-Code": { - "deprecation_date": "2026-08-27", - "cache_read_input_token_cost": 1.9e-07, - "input_cost_per_token": 9.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 4e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "together_ai/moonshotai/Kimi-K3": { "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, @@ -48441,33 +43891,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "together_ai/nvidia/nemotron-3-ultra-550b-a55b": { - "deprecation_date": "2026-08-27", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 512288, - "max_tokens": 512288, - "mode": "chat", - "output_cost_per_token": 3.6e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/pearl-ai/gemma-4-31b-it": { - "deprecation_date": "2026-08-27", - "input_cost_per_token": 2.8e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 8.6e-07, - "source": "https://docs.together.ai/docs/serverless-models" - }, "together_ai/thinkingmachines/Inkling": { "cache_read_input_token_cost": 1.7e-07, "input_cost_per_token": 1e-06, @@ -48483,18 +43906,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/thinkingmachines/Inkling-Small": { - "deprecation_date": "2026-09-14", - "cache_read_input_token_cost": 1e-07, - "input_cost_per_token": 5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 524288, - "max_tokens": 524288, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://api.together.ai/v1/models", - "supports_prompt_caching": true - }, "together_ai/zai-org/GLM-5.2": { "cache_read_input_token_cost": 2.6e-07, "input_cost_per_token": 1.4e-06, @@ -48641,22 +44052,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "us.amazon.nova-premier-v1:0": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 2.5e-06, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, - "max_output_tokens": 10000, - "max_tokens": 10000, - "mode": "chat", - "output_cost_per_token": 1.25e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_vision": true, - "cache_read_input_token_cost": 6.25e-07 - }, "us.amazon.nova-pro-v1:0": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 8e-07, @@ -48774,23 +44169,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "us.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "us.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", @@ -48806,23 +44184,6 @@ "cache_read_input_token_cost": 1.5e-06, "cache_creation_input_token_cost": 1.875e-05 }, - "us.anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "us.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, @@ -48887,23 +44248,6 @@ "input_cost_per_token_batches": 1.65e-06, "output_cost_per_token_batches": 8.25e-06 }, - "us-gov.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 3e-07, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-08, - "cache_creation_input_token_cost": 3.75e-07 - }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -50031,34 +45375,6 @@ "output_cost_per_token": 9e-07, "supports_tool_choice": true }, - "vercel_ai_gateway/google/gemini-2.0-flash": { - "deprecation_date": "2026-06-01", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "vercel_ai_gateway", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_vision": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true - }, - "vercel_ai_gateway/google/gemini-2.0-flash-lite": { - "deprecation_date": "2026-06-01", - "input_cost_per_token": 7.5e-08, - "litellm_provider": "vercel_ai_gateway", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "supports_vision": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true - }, "vercel_ai_gateway/google/gemini-2.5-flash": { "input_cost_per_token": 3e-07, "litellm_provider": "vercel_ai_gateway", @@ -50942,28 +46258,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "vertex_ai/claude-3-7-sonnet@20250219": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-05-11", - "input_cost_per_token": 3e-06, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "vertex_ai/claude-3-haiku": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-anthropic_models", @@ -51042,72 +46336,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "vertex_ai/claude-opus-4": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "vertex_ai/claude-opus-4-1": { - "deprecation_date": "2026-08-05", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "input_cost_per_token_batches": 7.5e-06, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "output_cost_per_token_batches": 3.75e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "vertex_ai/claude-opus-4-1@20250805": { - "deprecation_date": "2026-08-05", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "input_cost_per_token_batches": 7.5e-06, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "output_cost_per_token_batches": 3.75e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, "vertex_ai/claude-opus-4-5": { "deprecation_date": "2026-11-24", "cache_creation_input_token_cost": 6.25e-06, @@ -51708,98 +46936,6 @@ "supports_native_streaming": true, "prompt_cache_min_tokens": 1024 }, - "vertex_ai/claude-opus-4@20250514": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "vertex_ai/claude-sonnet-4": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "vertex_ai/claude-sonnet-4@20250514": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "vertex_ai/mistralai/codestral-2@001": { "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-mistral_models", @@ -51988,6 +47124,7 @@ "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, "deprecation_date": "2027-05-28", @@ -52012,7 +47149,8 @@ "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05, "supports_reasoning": false, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "cache_read_input_token_cost_batches": 1e-07 }, "vertex_ai/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -52032,6 +47170,7 @@ }, "vertex_ai/gemini-3.1-flash-image": { "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, @@ -52049,7 +47188,8 @@ "output_cost_per_token_batches": 1.5e-06, "output_cost_per_token_flex": 1.5e-06, "supports_reasoning": false, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "cache_read_input_token_cost_batches": 2.5e-08 }, "vertex_ai/gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -52067,6 +47207,7 @@ }, "vertex_ai/gemini-3.1-flash-lite-image": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, @@ -52104,7 +47245,8 @@ "supports_response_schema": false, "supports_system_messages": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "vertex_ai/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -52159,6 +47301,7 @@ "cache_read_input_audio_token_cost": 5e-08, "deprecation_date": "2027-05-07", "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_audio_token": 5e-07, @@ -52214,11 +47357,13 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_audio_token_batches": 2.5e-07 + "input_cost_per_audio_token_batches": 2.5e-07, + "cache_read_input_token_cost_batches": 1.25e-08 }, "vertex_ai/gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_token": 3e-07, @@ -52272,10 +47417,12 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 1.5e-08 }, "vertex_ai/deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_batches": 1e-07, "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, @@ -52288,63 +47435,8 @@ "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" - }, - "vertex_ai/imagegeneration@006": { - "deprecation_date": "2025-09-24", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-fast-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-generate-002": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-capability-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/image/edit-insert-objects" - }, - "vertex_ai/imagen-4.0-fast-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-4.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-4.0-ultra-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "cache_read_input_token_cost_batches": 1e-07 }, "vertex_ai/jamba-1.5": { "input_cost_per_token": 2e-07, @@ -53090,51 +48182,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "vertex_ai/veo-2.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-video-models", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.35, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, - "vertex_ai/veo-3.0-fast-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-video-models", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.15, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, - "vertex_ai/veo-3.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-video-models", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.4, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, "vertex_ai/veo-3.1-generate-preview": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, @@ -53423,59 +48470,6 @@ "mode": "chat", "source": "https://wandb.ai/site/pricing/tokens/" }, - "wandb/zai-org/GLM-4.5": { - "deprecation_date": "2026-03-04", - "supports_reasoning": true, - "max_tokens": 131072, - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "input_cost_per_token": 0.055, - "output_cost_per_token": 0.2, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": { - "deprecation_date": "2026-08-04", - "max_tokens": 262144, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 1e-07, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": { - "deprecation_date": "2026-08-25", - "max_tokens": 262144, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "input_cost_per_token": 1e-06, - "output_cost_per_token": 1.5e-06, - "litellm_provider": "wandb", - "mode": "chat", - "source": "https://wandb.ai/site/pricing/tokens/" - }, - "wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": { - "deprecation_date": "2026-08-04", - "supports_reasoning": true, - "max_tokens": 262144, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 1e-07, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/moonshotai/Kimi-K2-Instruct": { - "deprecation_date": "2026-03-04", - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 6e-07, - "output_cost_per_token": 2.5e-06, - "litellm_provider": "wandb", - "mode": "chat" - }, "wandb/moonshotai/Kimi-K2.5": { "max_tokens": 262144, "max_input_tokens": 262144, @@ -53491,20 +48485,6 @@ "supports_response_schema": true, "supports_vision": true }, - "wandb/MiniMaxAI/MiniMax-M2.5": { - "deprecation_date": "2026-08-25", - "max_tokens": 197000, - "max_input_tokens": 197000, - "max_output_tokens": 197000, - "input_cost_per_token": 3e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "wandb", - "mode": "chat", - "source": "https://wandb.ai/inference/coreweave/cw_MiniMaxAI_MiniMax-M2.5", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true - }, "wandb/meta-llama/Llama-3.1-8B-Instruct": { "max_tokens": 128000, "max_input_tokens": 131000, @@ -53526,27 +48506,6 @@ "mode": "chat", "source": "https://wandb.ai/site/pricing/tokens/" }, - "wandb/deepseek-ai/DeepSeek-R1-0528": { - "deprecation_date": "2026-03-04", - "supports_reasoning": true, - "max_tokens": 161000, - "max_input_tokens": 161000, - "max_output_tokens": 161000, - "input_cost_per_token": 1.35e-06, - "output_cost_per_token": 5.4e-06, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/deepseek-ai/DeepSeek-V3-0324": { - "deprecation_date": "2026-03-04", - "max_tokens": 161000, - "max_input_tokens": 161000, - "max_output_tokens": 161000, - "input_cost_per_token": 1.14e-06, - "output_cost_per_token": 2.75e-06, - "litellm_provider": "wandb", - "mode": "chat" - }, "wandb/meta-llama/Llama-3.3-70B-Instruct": { "max_tokens": 128000, "max_input_tokens": 128000, @@ -53557,26 +48516,6 @@ "mode": "chat", "source": "https://wandb.ai/site/pricing/tokens/" }, - "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": { - "deprecation_date": "2026-04-21", - "max_tokens": 64000, - "max_input_tokens": 64000, - "max_output_tokens": 64000, - "input_cost_per_token": 1.7e-07, - "output_cost_per_token": 6.6e-07, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/microsoft/Phi-4-mini-instruct": { - "deprecation_date": "2026-08-04", - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 0.008, - "output_cost_per_token": 0.035, - "litellm_provider": "wandb", - "mode": "chat" - }, "watsonx/ibm/granite-3-8b-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "watsonx", @@ -53977,440 +48916,6 @@ "deprecation_date": "2027-02-26", "source": "https://developers.openai.com/api/docs/pricing" }, - "xai/grok-3": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-beta": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-fast-beta": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-fast-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini": { - "cache_read_input_token_cost": 2e-07, - "deprecation_date": "2026-02-28", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-beta": { - "cache_read_input_token_cost": 2e-07, - "deprecation_date": "2026-02-28", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-fast": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-fast-beta": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-fast-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4": { - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-fast-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-fast-non-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-0709": { - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-latest": { - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-reasoning-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-non-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-non-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-non-reasoning-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-non-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, "xai/grok-4.20-multi-agent-beta-0309": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 1.25e-06, @@ -54655,72 +49160,6 @@ "supports_vision": true, "supports_web_search": true }, - "xai/grok-code-fast": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.x.ai/v1/language-models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "input_cost_per_token_above_200k_tokens": 2e-06, - "output_cost_per_token_above_200k_tokens": 4e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "supports_response_schema": true, - "supports_vision": true, - "deprecation_date": "2026-05-15", - "input_cost_per_image_token": 1e-06 - }, - "xai/grok-code-fast-1": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.x.ai/v1/language-models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "input_cost_per_token_above_200k_tokens": 2e-06, - "output_cost_per_token_above_200k_tokens": 4e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "supports_response_schema": true, - "supports_vision": true, - "deprecation_date": "2026-05-15", - "input_cost_per_image_token": 1e-06 - }, - "xai/grok-code-fast-1-0825": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.x.ai/v1/language-models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "input_cost_per_token_above_200k_tokens": 2e-06, - "output_cost_per_token_above_200k_tokens": 4e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "supports_response_schema": true, - "supports_vision": true, - "deprecation_date": "2026-05-15", - "input_cost_per_image_token": 1e-06 - }, "zai.glm-4.7": { "input_cost_per_token": 6e-07, "litellm_provider": "bedrock_converse", @@ -57463,30 +51902,6 @@ "supports_reasoning": true, "supports_vision": true }, - "scaleway/google/gemma-3-27b-it": { - "input_cost_per_token": 2.5e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 40000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 5e-07, - "supports_function_calling": true, - "supports_vision": true, - "deprecation_date": "2026-08-01" - }, - "scaleway/hcompany/holo2-30b-a3b": { - "input_cost_per_token": 3e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 22000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 7e-07, - "supports_reasoning": true, - "supports_vision": true, - "deprecation_date": "2026-08-09" - }, "scaleway/mistralai/mistral-medium-3.5-128b": { "input_cost_per_token": 1.5e-06, "litellm_provider": "scaleway", @@ -57500,29 +51915,6 @@ "supports_vision": true, "supports_tool_choice": true }, - "scaleway/mistralai/devstral-2-123b-instruct-2512": { - "input_cost_per_token": 4e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 2e-06, - "supports_function_calling": true, - "deprecation_date": "2026-08-01" - }, - "scaleway/mistralai/voxtral-small-24b-2507": { - "input_cost_per_audio_token": 1.5e-07, - "input_cost_per_token": 1.5e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 32000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 3.5e-07, - "supports_audio_input": true, - "deprecation_date": "2026-08-01" - }, "scaleway/mistralai/mistral-small-3.2-24b-instruct-2506": { "input_cost_per_token": 1.5e-07, "litellm_provider": "scaleway", @@ -59082,26 +53474,6 @@ "/v1/audio/speech" ] }, - "gpt-4o-mini-tts-2025-03-20": { - "deprecation_date": "2026-07-23", - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "mode": "audio_speech", - "output_cost_per_audio_token": 1.2e-05, - "output_cost_per_second": 0.00025, - "output_cost_per_token": 1e-05, - "source": "https://developers.openai.com/api/docs/pricing", - "supported_endpoints": [ - "/v1/audio/speech" - ], - "supported_modalities": [ - "text", - "audio" - ], - "supported_output_modalities": [ - "audio" - ] - }, "gpt-4o-mini-tts-2025-12-15": { "input_cost_per_token": 6e-07, "litellm_provider": "openai", @@ -59205,41 +53577,6 @@ "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false }, - "gpt-realtime-mini-2025-10-06": { - "cache_creation_input_audio_token_cost": 3e-07, - "cache_read_input_audio_token_cost": 3e-07, - "cache_read_input_token_cost": 6e-08, - "deprecation_date": "2026-07-23", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_image_token": 8e-07, - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 2.4e-06, - "source": "https://developers.openai.com/api/docs/pricing", - "supported_endpoints": [ - "/v1/realtime" - ], - "supported_modalities": [ - "text", - "image", - "audio" - ], - "supported_output_modalities": [ - "text", - "audio" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-realtime-mini-2025-12-15": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, @@ -59394,42 +53731,6 @@ "tpm": 250000, "rpm": 10 }, - "gemini/gemini-2.0-flash-lite-001": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_token": 7.5e-08, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "rpm": 4000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.0-flash-lite", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 4000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 5e-07, @@ -65589,23 +59890,6 @@ "image" ] }, - "xai/grok-imagine-image-pro": { - "input_cost_per_image": 0.05, - "litellm_provider": "xai", - "mode": "image_generation", - "source": "https://docs.x.ai/docs/models", - "supported_endpoints": [ - "/v1/images/generations" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "image" - ], - "deprecation_date": "2026-05-15" - }, "xai/grok-imagine-image-2.0": { "input_cost_per_image": 0.06, "litellm_provider": "xai", @@ -66223,16 +60507,6 @@ "output_cost_per_token": 2.82e-07, "source": "https://api.together.ai/v1/models" }, - "together_ai/moonshotai/Kimi-K2.6": { - "deprecation_date": "2026-08-19", - "input_cost_per_token": 1.2e-06, - "output_cost_per_token": 4.5e-06, - "cache_read_input_token_cost": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "together_ai/moonshotai/Kimi-K2.5-fp4": { "input_cost_per_token": 5e-07, "output_cost_per_token": 2.8e-06, @@ -66250,25 +60524,6 @@ "mode": "chat", "source": "https://api.together.ai/v1/models" }, - "together_ai/zai-org/GLM-5": { - "deprecation_date": "2026-06-22", - "input_cost_per_token": 1e-06, - "output_cost_per_token": 3.2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 202752, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, - "together_ai/zai-org/GLM-5.1": { - "deprecation_date": "2026-07-10", - "input_cost_per_token": 1.4e-06, - "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 2.6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 202752, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "together_ai/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, "output_cost_per_token": 7e-06, @@ -66277,33 +60532,6 @@ "mode": "chat", "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen3-Coder-Next-FP8": { - "deprecation_date": "2026-05-14", - "input_cost_per_token": 5e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen3-VL-32B-Instruct": { - "deprecation_date": "2026-02-25", - "input_cost_per_token": 5e-07, - "output_cost_per_token": 1.5e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen3-VL-8B-Instruct": { - "deprecation_date": "2026-04-16", - "input_cost_per_token": 1.8e-07, - "output_cost_per_token": 6.8e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "together_ai/mistralai/Ministral-3-14B-Instruct-2512": { "input_cost_per_token": 2e-07, "output_cost_per_token": 2e-07, @@ -66328,15 +60556,6 @@ "mode": "chat", "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/QwQ-32B": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 1.2e-06, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 131072, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "cerebras/gemma-4-31b": { "input_cost_per_token": 9.9e-07, "litellm_provider": "cerebras", @@ -68316,7 +62535,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://www.baseten.co/pricing/", + "source": "https://inference.baseten.co/v1/models", "supported_modalities": [ "text", "image" @@ -68326,6 +62545,7 @@ ], "supports_function_calling": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true @@ -69111,13 +63331,13 @@ "supports_web_search": false }, "openrouter/qwen/qwen3.6-27b": { - "input_cost_per_token": 3e-07, - "output_cost_per_token": 2e-06, - "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 3.2e-07, + "output_cost_per_token": 2.7e-06, + "cache_read_input_token_cost": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 262140, + "max_tokens": 262140, "mode": "chat", "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, @@ -71078,38 +65298,6 @@ "output_cost_per_token": 1.5e-07, "source": "https://api.together.ai/v1/models" }, - "together_ai/deepseek-ai/deepseek-coder-33b-instruct": { - "deprecation_date": "2024-08-22", - "input_cost_per_token": 8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { - "deprecation_date": "2025-12-23", - "input_cost_per_token": 2e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 1.6e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.6e-06, - "source": "https://api.together.ai/v1/models" - }, "vertex_ai/gemini-2.5-flash-native-audio": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 5e-07, @@ -71128,16 +65316,6 @@ "output_cost_per_token": 1e-05, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, - "vertex_ai/gemini-3.1-flash-live-preview": { - "input_cost_per_audio_token": 3e-06, - "input_cost_per_second": 8.33333333333e-05, - "input_cost_per_token": 7.5e-07, - "litellm_provider": "vertex_ai", - "mode": "realtime", - "output_cost_per_audio_token": 1.2e-05, - "output_cost_per_token": 4.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" - }, "vertex_ai/gemini-3.1-flash-tts-preview": { "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, @@ -71170,16 +65348,6 @@ "output_cost_per_token": 9e-06, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, - "vertex_ai/gemini-robotics-er-2": { - "cache_read_input_token_cost": 1e-07, - "input_cost_per_token": 1e-06, - "input_cost_per_token_batches": 5e-07, - "litellm_provider": "vertex_ai", - "mode": "chat", - "output_cost_per_token": 5e-06, - "output_cost_per_token_batches": 2.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" - }, "vertex_ai/gemma-4-26b-a4b-it": { "cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 1.5e-07, @@ -71188,14 +65356,6 @@ "output_cost_per_token": 6e-07, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, - "together_ai/google/gemma-2-27b-it": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://api.together.ai/v1/models" - }, "gpt-5.5-cyber": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 1.25e-05, @@ -71213,14 +65373,6 @@ "output_cost_per_token": 2.5e-05, "source": "https://developers.openai.com/api/docs/pricing" }, - "together_ai/meta-llama/Llama-3-8b-chat-hf": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://api.together.ai/v1/models" - }, "together_ai/meta-llama/Llama-3.1-405B-Instruct": { "input_cost_per_token": 3.5e-06, "litellm_provider": "together_ai", @@ -71242,38 +65394,6 @@ "output_cost_per_token": 6e-08, "source": "https://api.together.ai/v1/models" }, - "together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo": { - "deprecation_date": "2025-12-23", - "input_cost_per_token": 8.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/meta-llama/Meta-Llama-3-8B-Instruct": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 8.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.8e-07, - "source": "https://api.together.ai/v1/models" - }, "together_ai/Qwen/Qwen2-1.5B-Instruct": { "input_cost_per_token": 2e-08, "litellm_provider": "together_ai", @@ -71281,22 +65401,6 @@ "output_cost_per_token": 2e-08, "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen2-72B-Instruct": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 9e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 9e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen2-VL-72B-Instruct": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 1.2e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://api.together.ai/v1/models" - }, "together_ai/Qwen/Qwen2.5-14B-Instruct": { "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", @@ -71311,22 +65415,6 @@ "output_cost_per_token": 1.2e-06, "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen2.5-Coder-32B-Instruct": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen2.5-VL-72B-Instruct": { - "deprecation_date": "2026-01-05", - "input_cost_per_token": 1.95e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-06, - "source": "https://api.together.ai/v1/models" - }, "azure/eu/codex-mini": { "deprecation_date": "2026-11-15", "cache_read_input_token_cost": 4.13e-07, @@ -71477,15 +65565,6 @@ "output_cost_per_token_priority": 3.08e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/gpt-5.2-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/gpt-5.2-codex": { "deprecation_date": "2027-07-13", "cache_read_input_token_cost": 1.925e-07, @@ -71504,15 +65583,6 @@ "output_cost_per_token_batches": 9.24e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/gpt-5.3-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/gpt-5.3-codex": { "deprecation_date": "2027-08-24", "cache_read_input_token_cost": 1.925e-07, @@ -71589,14 +65659,6 @@ "output_cost_per_token_batches": 2.42e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/o1-preview": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/o3-2025-04-16": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, @@ -71839,15 +65901,6 @@ "output_cost_per_token_priority": 3.08e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/gpt-5.2-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/gpt-5.2-codex": { "deprecation_date": "2027-07-13", "cache_read_input_token_cost": 1.925e-07, @@ -71866,15 +65919,6 @@ "output_cost_per_token_batches": 9.24e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/gpt-5.3-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/gpt-5.3-codex": { "deprecation_date": "2027-08-24", "cache_read_input_token_cost": 1.925e-07, @@ -71938,14 +65982,6 @@ "output_cost_per_token_batches": 2.42e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/o1-preview": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/o3-deep-research": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 2.75e-06, @@ -73191,16 +67227,16 @@ "supports_web_search": true }, "openrouter/~anthropic/claude-opus-latest": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_1hr": 1e-05, - "cache_read_input_token_cost": 5e-07, - "input_cost_per_token": 5e-06, + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, "litellm_provider": "openrouter", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 2.5e-05, + "output_cost_per_token": 2e-05, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73235,15 +67271,14 @@ "supports_web_search": true }, "openrouter/~deepseek/deepseek-flash-latest": { - "cache_read_input_token_cost": 6e-09, - "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 3.6e-09, + "input_cost_per_token": 1.2e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 943718, + "max_tokens": 943718, "mode": "chat", - "off_peak_pricing": {"windows":[{"weekdays":["saturday","sunday"],"hours_utc":"00:00-00:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"00:00-01:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"04:00-06:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"10:00-00:00"}],"input_cost_per_token":1.5e-7,"output_cost_per_token":6e-7,"cache_read_input_token_cost":3e-9}, - "output_cost_per_token": 1.2e-06, + "output_cost_per_token": 4.8e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73256,15 +67291,14 @@ "supports_web_search": false }, "openrouter/~deepseek/deepseek-pro-latest": { - "cache_read_input_token_cost": 4.4e-08, - "input_cost_per_token": 1.32e-06, + "cache_read_input_token_cost": 1.2726e-08, + "input_cost_per_token": 3.9996e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "off_peak_pricing": {"windows":[{"weekdays":["saturday","sunday"],"hours_utc":"00:00-00:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"00:00-01:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"04:00-06:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"10:00-00:00"}],"input_cost_per_token":6.6e-7,"output_cost_per_token":0.00000198,"cache_read_input_token_cost":2.2e-8}, - "output_cost_per_token": 3.96e-06, + "output_cost_per_token": 1.19988e-06, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73277,14 +67311,14 @@ "supports_web_search": false }, "openrouter/~deepseek/deepseek-v4-flash-latest": { - "cache_read_input_token_cost": 1.6e-08, - "input_cost_per_token": 4e-08, + "cache_read_input_token_cost": 8e-09, + "input_cost_per_token": 3e-08, "litellm_provider": "openrouter", "max_input_tokens": 1310720, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "output_cost_per_token": 6.4e-07, + "output_cost_per_token": 8e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73347,13 +67381,13 @@ }, "openrouter/~moonshotai/kimi-latest": { "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, + "input_cost_per_token": 1.4989e-06, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "output_cost_per_token": 1.5e-05, + "output_cost_per_token": 1.0758e-05, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73391,19 +67425,19 @@ "supports_web_search": true }, "openrouter/~openai/gpt-luna-latest": { - "cache_creation_input_token_cost": 2.5e-07, - "cache_creation_input_token_cost_above_272k_tokens": 5e-07, - "cache_read_input_token_cost": 2e-08, - "cache_read_input_token_cost_above_272k_tokens": 4e-08, - "input_cost_per_token": 2e-07, - "input_cost_per_token_above_272k_tokens": 4e-07, + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, "litellm_provider": "openrouter", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_above_272k_tokens": 1.8e-06, + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73509,14 +67543,14 @@ "supports_web_search": true }, "openrouter/~z-ai/glm-flash-latest": { - "cache_read_input_token_cost": 5e-08, - "input_cost_per_token": 1.5e-07, + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 7.5e-08, "litellm_provider": "openrouter", "max_input_tokens": 1310720, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "output_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -74291,85 +68325,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/deepseek/deepseek-v4-flash-0731:batch": { - "cache_read_input_token_cost": 3.5e-09, - "input_cost_per_token": 1.1e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 3.3e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, - "openrouter/deepseek/deepseek-v4-flash-0731:free": { - "input_cost_per_token": 0.0, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 393216, - "max_tokens": 393216, - "mode": "chat", - "output_cost_per_token": 0.0, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": false, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, - "openrouter/deepseek/deepseek-v4-flash-vision-exp:batch": { - "cache_read_input_token_cost": 3.5e-09, - "input_cost_per_token": 1.1e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 3.3e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, - "openrouter/deepseek/deepseek-v4-pro-0813:batch": { - "cache_read_input_token_cost": 2.2e-08, - "input_cost_per_token": 6.6e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 1.98e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/dots-studio/dots-3-note-preview:free": { "deprecation_date": "2026-12-31", "input_cost_per_token": 0.0, @@ -74885,26 +68840,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/kwaipilot/kat-coder-pro-v2": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 3e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 144000, - "max_tokens": 144000, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/kwaipilot/kat-coder-pro-v2.5": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 7.4e-07, @@ -74984,26 +68919,6 @@ "supports_vision": true, "supports_web_search": false }, - "openrouter/meta/muse-glimmer-30b:batch": { - "cache_read_input_token_cost": 2e-08, - "input_cost_per_token": 1.75e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 131072, - "max_output_tokens": 117964, - "max_tokens": 117964, - "mode": "chat", - "output_cost_per_token": 7.5e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/meta/muse-spark-1.1": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.25e-06, @@ -75142,26 +69057,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/minimax/minimax-m3:batch": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 3e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 524288, - "max_output_tokens": 471859, - "max_tokens": 471859, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/mistralai/codestral-2508:batch": { "cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 1.5e-07, @@ -76111,24 +70006,105 @@ "supports_vision": true, "supports_web_search": true }, - "openrouter/openai/gpt-oss-120b:batch": { - "input_cost_per_token": 1.5e-07, + "openrouter/openai/gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, "litellm_provider": "openrouter", - "max_input_tokens": 131072, - "max_output_tokens": 117964, - "max_tokens": 117964, + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 6e-07, + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-6-luna-pro": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-6-sol-pro": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true }, "openrouter/openai/o3-mini:batch": { "cache_read_input_token_cost": 2.75e-07, @@ -76304,45 +70280,6 @@ "supports_vision": true, "supports_web_search": true }, - "openrouter/qwen/qwen3.5-9b:batch": { - "input_cost_per_token": 1.7e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 235929, - "max_tokens": 235929, - "mode": "chat", - "output_cost_per_token": 2.5e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": false, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, - "openrouter/qwen/qwen3.8-2.4t-a95b:batch": { - "cache_read_input_token_cost": 2.5e-07, - "input_cost_per_token": 2e-06, - "litellm_provider": "openrouter", - "max_input_tokens": 1010000, - "max_output_tokens": 909000, - "max_tokens": 909000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/qwen/qwen3.8-27b:free": { "input_cost_per_token": 0.0, "litellm_provider": "openrouter", @@ -76875,26 +70812,6 @@ "supports_vision": true, "supports_web_search": false }, - "openrouter/thinkingmachines/inkling:batch": { - "cache_read_input_token_cost": 1.7e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "openrouter", - "max_input_tokens": 524288, - "max_output_tokens": 471859, - "max_tokens": 471859, - "mode": "chat", - "output_cost_per_token": 4.05e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/thinkingmachines/inkling:free": { "input_cost_per_token": 0.0, "litellm_provider": "openrouter", @@ -77016,26 +70933,6 @@ "supports_vision": true, "supports_web_search": true }, - "openrouter/z-ai/glm-5.2:batch": { - "cache_read_input_token_cost": 7e-08, - "input_cost_per_token": 7e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 2.2e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/z-ai/glm-5.3-flash:batch": { "cache_read_input_token_cost": 1.2e-08, "input_cost_per_token": 6e-08, @@ -77765,5 +71662,226 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": false + }, + "baseten/deepseek-ai/DeepSeek-V4.1-Flash": { + "cache_read_input_token_cost": 7e-09, + "input_cost_per_token": 3e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/moonshotai/Kimi-K2.6": { + "cache_read_input_token_cost": 1.6e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 262000, + "max_output_tokens": 262000, + "max_tokens": 262000, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/moonshotai/Kimi-K2.7-Code": { + "cache_read_input_token_cost": 1.6e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 262000, + "max_output_tokens": 262000, + "max_tokens": 262000, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/moonshotai/Kimi-K3": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "max_input_tokens": 202800, + "max_output_tokens": 202800, + "max_tokens": 202800, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/thinkingmachines/inkling": { + "cache_read_input_token_cost": 1.7e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4.05e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/thinkingmachines/inkling-small": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/zai-org/GLM-5.2": { + "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/zai-org/GLM-5.3-Flash": { + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/deepseek-ai/DeepSeek-V4-Flash-0731": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.3e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 2.6e-07, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/deepseek-ai/DeepSeek-V4-Pro": { + "cache_read_input_token_cost": 1.45e-07, + "input_cost_per_token": 1.74e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.48e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/deepseek-ai/DeepSeek-V4-Pro-0813": { + "cache_read_input_token_cost": 1.32e-07, + "input_cost_per_token": 1.32e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.96e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/zai-org/GLM-5.2-Fast": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true } } diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 6b8006dbfc9..a012e66f5fa 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -665,6 +665,8 @@ class MCPRequestHandler: # with ``server.py::_get_mcp_servers_in_path``, which also accepts the # un-rewritten form (some entry points may skip the # ``dynamic_mcp_route`` rewrite). + if path.rstrip("/") in ("/mcp/sse", "/mcp/sse/messages"): + return [] segments: Final = [s for s in path.split("/") if s] if len(segments) >= 2 and segments[1] == "mcp" and segments[0] != "mcp": return [segments[0]] diff --git a/litellm/proxy/_experimental/mcp_server/operations.py b/litellm/proxy/_experimental/mcp_server/operations.py index bbe2987b856..e4aa7fb01aa 100644 --- a/litellm/proxy/_experimental/mcp_server/operations.py +++ b/litellm/proxy/_experimental/mcp_server/operations.py @@ -2194,7 +2194,7 @@ async def _fire_mcp_tool_call_logging( from litellm.proxy.proxy_server import proxy_logging_obj logging_obj.post_call(original_response=result) - await logging_obj.async_post_mcp_tool_call_hook( + result = await logging_obj.async_post_mcp_tool_call_hook( kwargs=logging_obj.model_call_details, response_obj=result, start_time=start_time, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index f160ce0f4a9..5b807dc95a9 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -21,6 +21,7 @@ from fastapi import FastAPI, HTTPException from pydantic import ConfigDict, TypeAdapter, ValidationError from starlette.requests import Request as StarletteRequest from starlette.responses import JSONResponse +from starlette.routing import Route from starlette.types import Message, Receive, Scope, Send from litellm._logging import verbose_logger @@ -474,6 +475,8 @@ if MCP_AVAILABLE: AuthContextMiddleware, auth_context_var, ) + from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser + from mcp.server.auth.provider import AccessToken from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.server import NotificationOptions from mcp.server.models import InitializationOptions @@ -573,7 +576,7 @@ if MCP_AVAILABLE: version=LITELLM_MCP_SERVER_VERSION, ) server.create_initialization_options = types.MethodType(_gateway_create_initialization_options, server) - sse: Final[SseServerTransport] = SseServerTransport("/mcp/sse/messages") + sse: Final[SseServerTransport] = SseServerTransport("/sse/messages") # Create session managers session_manager_stateless: Final = StreamableHTTPSessionManager( @@ -629,18 +632,9 @@ if MCP_AVAILABLE: # Keep this alias so existing references to session_manager still work session_manager: Final = session_manager_stateless - # Create SSE session manager - sse_session_manager: Final = StreamableHTTPSessionManager( - app=server, - event_store=None, - json_response=False, # Use SSE responses for this endpoint - stateless=True, - ) - # Context managers for proper lifecycle management _session_manager_cm = None _session_manager_stateful_cm = None - _sse_session_manager_cm = None _stateful_auth_context_cleanup_task: asyncio.Task | None = None async def _purge_expired_stateful_session_auth_contexts( @@ -732,7 +726,6 @@ if MCP_AVAILABLE: _SESSION_MANAGERS_INITIALIZED, \ _session_manager_cm, \ _session_manager_stateful_cm, \ - _sse_session_manager_cm, \ _stateful_auth_context_cleanup_task # Use async lock to prevent concurrent initialization @@ -745,12 +738,10 @@ if MCP_AVAILABLE: # Start the session managers with context managers _session_manager_cm = session_manager_stateless.run() _session_manager_stateful_cm = session_manager_stateful.run() - _sse_session_manager_cm = sse_session_manager.run() # Enter the context managers await _session_manager_cm.__aenter__() await _session_manager_stateful_cm.__aenter__() - await _sse_session_manager_cm.__aenter__() _stateful_auth_context_cleanup_task = asyncio.create_task(_cleanup_expired_stateful_session_auth_contexts()) _SESSION_MANAGERS_INITIALIZED = True @@ -762,7 +753,6 @@ if MCP_AVAILABLE: _SESSION_MANAGERS_INITIALIZED, \ _session_manager_cm, \ _session_manager_stateful_cm, \ - _sse_session_manager_cm, \ _stateful_auth_context_cleanup_task if _SESSION_MANAGERS_INITIALIZED: @@ -773,8 +763,6 @@ if MCP_AVAILABLE: _stateful_auth_context_cleanup_task.cancel() with contextlib.suppress(asyncio.CancelledError): await _stateful_auth_context_cleanup_task - if _sse_session_manager_cm: - await _sse_session_manager_cm.__aexit__(None, None, None) if _session_manager_stateful_cm: await _session_manager_stateful_cm.__aexit__(None, None, None) if _session_manager_cm: @@ -784,7 +772,6 @@ if MCP_AVAILABLE: _session_manager_cm = None _session_manager_stateful_cm = None - _sse_session_manager_cm = None _stateful_auth_context_cleanup_task = None _SESSION_MANAGERS_INITIALIZED = False @@ -1054,6 +1041,8 @@ if MCP_AVAILABLE: """ import re + if path.rstrip("/") in ("/mcp/sse", "/mcp/sse/messages"): + return None mcp_servers_from_path: list[str] | None = None segments: Final = [s for s in path.split("/") if s] if len(segments) >= 2 and segments[1] == "mcp" and segments[0] != "mcp": @@ -2289,7 +2278,24 @@ if MCP_AVAILABLE: async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None: """Handle MCP requests through SSE.""" try: - path: Final[str] = scope.get("path", "") + bad_version: Final = unsupported_protocol_version(scope) + if bad_version is not None: + supported: Final = ", ".join(sorted(HANDSHAKE_PROTOCOL_VERSIONS)) + await JSONResponse( + status_code=400, + content={ # mutable-ok: JSON-RPC error payload + "jsonrpc": "2.0", + "id": None, + "error": { + "code": INVALID_REQUEST, + "message": f"Unsupported MCP-Protocol-Version {bad_version}; supported: {supported}", + }, + }, + )(scope, receive, send) + return + from litellm.proxy.auth.auth_utils import get_request_route + + path: Final = get_request_route(StarletteRequest(scope)) ( user_api_key_auth, mcp_auth_header, @@ -2356,9 +2362,14 @@ if MCP_AVAILABLE: client_ip=_sse_client_ip, ) - if not _SESSION_MANAGERS_INITIALIZED: - await initialize_session_managers() - await asyncio.sleep(0.1) + owner: Final = _owner_fingerprint_for(user_api_key_auth, oauth2_headers, _sse_client_ip) + transport_scope: Final[Scope] = { + **scope, + "user": AuthenticatedUser(AccessToken(token=owner, client_id=owner, scopes=[])), + } + if scope["method"] == "POST": + await sse.handle_post_message(transport_scope, receive, send) + return async with _gateway_initialize_instructions_request_scope( user_api_key_auth, @@ -2367,7 +2378,8 @@ if MCP_AVAILABLE: scoped_server_endpoint=scoped_server_endpoint, is_initialize=scope.get("method") == "GET", ): - await sse_session_manager.handle_request(scope, receive, send) + async with sse.connect_sse(transport_scope, receive, send) as (read_stream, write_stream): + await server.run(read_stream, write_stream, server.create_initialization_options()) except MCPUpstreamAuthError as e: # Upstream delegated auth returned 401; surface it to the client so # standards-compliant MCP clients trigger the upstream OAuth flow. @@ -2390,7 +2402,6 @@ if MCP_AVAILABLE: # Try to send a graceful error response for non-HTTP exceptions try: # Send a proper HTTP error response instead of letting the exception bubble up - from starlette.responses import JSONResponse from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR error_response: Final = JSONResponse( @@ -2421,11 +2432,22 @@ if MCP_AVAILABLE: """ return {"enabled": MCP_AVAILABLE} + class _LegacySseEndpoint: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + await handle_sse_mcp(scope, receive, send) + + for sse_path, sse_method in ( + ("/sse", "GET"), + ("/sse/", "GET"), + ("/sse/messages", "POST"), + ("/sse/messages/", "POST"), + ): + app.router.routes.append(Route(sse_path, endpoint=_LegacySseEndpoint(), methods=[sse_method])) + # Mount the MCP handlers app.mount("/", handle_streamable_http_mcp) app.mount("/mcp", handle_streamable_http_mcp) app.mount("/{mcp_server_name}/mcp", handle_streamable_http_mcp) - app.mount("/sse", handle_sse_mcp) app.add_middleware(AuthContextMiddleware) ######################################################## diff --git a/litellm/proxy/_experimental/mcp_server/sse_transport.py b/litellm/proxy/_experimental/mcp_server/sse_transport.py index 2a08a5f8f7a..839d2eebbbe 100644 --- a/litellm/proxy/_experimental/mcp_server/sse_transport.py +++ b/litellm/proxy/_experimental/mcp_server/sse_transport.py @@ -1,138 +1,3 @@ -""" -This is a modification of code from: https://github.com/SecretiveShell/MCP-Bridge/blob/master/mcp_bridge/mcp_server/sse_transport.py +from mcp.server.sse import SseServerTransport -Credit to the maintainers of SecretiveShell for their SSE Transport implementation - -""" - -from contextlib import asynccontextmanager -from typing import Any, Final -from urllib.parse import quote -from uuid import UUID, uuid4 - -import anyio -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from fastapi.requests import Request -from fastapi.responses import Response -from mcp import types -from pydantic import ValidationError -from sse_starlette import EventSourceResponse -from starlette.types import Receive, Scope, Send - -from litellm._logging import verbose_logger - - -class SseServerTransport: - """ - SSE server transport for MCP. This class provides _two_ ASGI applications, - suitable to be used with a framework like Starlette and a server like Hypercorn: - - 1. connect_sse() is an ASGI application which receives incoming GET requests, - and sets up a new SSE stream to send server messages to the client. - 2. handle_post_message() is an ASGI application which receives incoming POST - requests, which should contain client messages that link to a - previously-established SSE session. - """ - - _endpoint: str - _read_stream_writers: dict[UUID, MemoryObjectSendStream[types.JSONRPCMessage | Exception]] - - def __init__(self, endpoint: str) -> None: - """ - Creates a new SSE server transport, which will direct the client to POST - messages to the relative or absolute URL given. - """ - - super().__init__() - self._endpoint = endpoint - self._read_stream_writers = {} - verbose_logger.debug("SseServerTransport initialized with endpoint: %s", endpoint) - - @asynccontextmanager - async def connect_sse(self, request: Request): - if request.scope["type"] != "http": - verbose_logger.error("connect_sse received non-HTTP request") - raise ValueError("connect_sse can only handle HTTP requests") - - verbose_logger.debug("Setting up SSE connection") - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception] - read_stream_writer: MemoryObjectSendStream[types.JSONRPCMessage | Exception] - - write_stream: MemoryObjectSendStream[types.JSONRPCMessage] - write_stream_reader: MemoryObjectReceiveStream[types.JSONRPCMessage] - - read_stream_writer, read_stream = anyio.create_memory_object_stream(0) - write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - - session_id: Final = uuid4() - session_uri: Final = f"{quote(self._endpoint)}?session_id={session_id.hex}" - self._read_stream_writers[session_id] = read_stream_writer - verbose_logger.debug("Created new session with ID: %s", session_id) - - sse_stream_writer: MemoryObjectSendStream[dict[str, Any]] - sse_stream_reader: MemoryObjectReceiveStream[dict[str, Any]] - sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream(0, dict[str, Any]) - - async def sse_writer(): - verbose_logger.debug("Starting SSE writer") - async with sse_stream_writer, write_stream_reader: - await sse_stream_writer.send({"event": "endpoint", "data": session_uri}) - verbose_logger.debug("Sent endpoint event: %s", session_uri) - - async for message in write_stream_reader: - verbose_logger.debug("Sending message via SSE: %s", message) - await sse_stream_writer.send( - { - "event": "message", - "data": message.model_dump_json(by_alias=True, exclude_none=True), - } - ) - - async with anyio.create_task_group() as tg: - response: Final = EventSourceResponse(content=sse_stream_reader, data_sender_callable=sse_writer) - verbose_logger.debug("Starting SSE response task") - tg.start_soon(response, request.scope, request.receive, request._send) - - verbose_logger.debug("Yielding read and write streams") - yield (read_stream, write_stream) - - async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> Response: - verbose_logger.debug("Handling POST message") - request: Final = Request(scope, receive) - - session_id_param: Final = request.query_params.get("session_id") - if session_id_param is None: - verbose_logger.warning("Received request without session_id") - response = Response("session_id is required", status_code=400) - return response - - try: - session_id: Final = UUID(hex=session_id_param) - verbose_logger.debug("Parsed session ID: %s", session_id) - except ValueError: - verbose_logger.warning("Received invalid session ID: %s", session_id_param) - response = Response("Invalid session ID", status_code=400) - return response - - writer: Final = self._read_stream_writers.get(session_id) - if not writer: - verbose_logger.warning("Could not find session for ID: %s", session_id) - response = Response("Could not find session", status_code=404) - return response - - json: Final = await request.json() - verbose_logger.debug("Received JSON: %s", json) - - try: - message: Final = types.JSONRPCMessage.model_validate(json) - verbose_logger.debug("Validated client message: %s", message) - except ValidationError as err: - verbose_logger.error("Failed to parse message: %s", err) - response = Response("Could not parse message", status_code=400) - await writer.send(err) - return response - - verbose_logger.debug("Sending message to writer: %s", message) - response = Response("Accepted", status_code=202) - await writer.send(message) - return response +__all__ = ("SseServerTransport",) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 4dbca14b917..87f28624d58 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19919,7 +19919,7 @@ } } }, - "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " + "description": "Unified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values." }, "500": { "content": { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8b2c81fea77..76df8790b92 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -533,6 +533,9 @@ class LiteLLMRoutes(enum.Enum): mcp_inference_routes = [ "/mcp", "/mcp/", + "/mcp/sse/", + "/mcp/sse/messages", + "/mcp/sse/messages/", "/mcp/proxy", "/mcp/{subpath}", "/mcp/tools", diff --git a/litellm/proxy/bug_report_config.py b/litellm/proxy/bug_report_config.py index 527be65c9a7..d7b920de4d5 100644 --- a/litellm/proxy/bug_report_config.py +++ b/litellm/proxy/bug_report_config.py @@ -11,7 +11,14 @@ from typing import Final from pydantic import JsonValue, TypeAdapter, ValidationError import litellm -from litellm.litellm_core_utils.bug_report import KNOWN_PROVIDERS, BugReport, allowlisted, build_bug_report +from litellm.litellm_core_utils.bug_report import ( + KNOWN_PROVIDERS, + BugReport, + EnvironmentReport, + allowlisted, + build_bug_report, + build_environment_report, +) from litellm.proxy._types import ConfigGeneralSettings from litellm.router_utils.routing_groups import VALID_ROUTING_STRATEGIES from litellm.types.caching import LiteLLMCacheType @@ -191,6 +198,19 @@ def safe_config_lines(config: Mapping[str, object], general_settings: Mapping[st ) +def _proxy_config_lines() -> tuple[str, ...]: + from litellm.proxy import proxy_server + + return safe_config_lines( + proxy_server.proxy_config.config, + _object_map(proxy_server.general_settings), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # bare dict global, validated by _object_map + ) + + +def build_proxy_environment_report() -> EnvironmentReport: + return build_environment_report(surface="proxy", config_lines=_proxy_config_lines()) + + def build_proxy_bug_report( exc: BaseException, *, @@ -198,16 +218,11 @@ def build_proxy_bug_report( custom_llm_provider: object = None, stream: object = None, ) -> BugReport: - from litellm.proxy import proxy_server - return build_bug_report( exc, surface="proxy", call_type=call_type, custom_llm_provider=custom_llm_provider, stream=stream, - config_lines=safe_config_lines( - proxy_server.proxy_config.config, - _object_map(proxy_server.general_settings), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # bare dict global, validated by _object_map - ), + config_lines=_proxy_config_lines(), ) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 6a7b6970bb8..c40090233be 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -26,7 +26,7 @@ import httpx import orjson from fastapi import HTTPException, Request, status from fastapi.responses import JSONResponse, Response, StreamingResponse -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from starlette.types import Receive, Scope, Send import litellm @@ -56,6 +56,7 @@ from litellm.litellm_core_utils.core_helpers import ( get_or_create_metadata_bucket, independent_snapshot, is_expected_client_error, + redact_nested_match_and_regex_keys, ) from litellm.litellm_core_utils.dd_tracing import NullTracer, tracer from litellm.litellm_core_utils.get_supported_openai_params import ( @@ -1401,6 +1402,42 @@ def _override_openai_response_model( ) +_METADATA_BUCKET_KEYS: Final = ("metadata", "litellm_metadata") +_RESPONSE_REDACTED_KEYS: Final = ("keyword", "snippet", "match", "regex") + + +def _request_metadata_buckets(request_data: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: + return tuple(bucket for key in _METADATA_BUCKET_KEYS if isinstance(bucket := request_data.get(key), Mapping)) + + +def include_guardrail_response_requested(request_data: Mapping[str, object]) -> bool: + return any(bucket.get("include_guardrail_response") is True for bucket in _request_metadata_buckets(request_data)) + + +def attach_guardrail_information(response: object, request_data: Mapping[str, object]) -> object: + recorded: Final[Sequence[object]] = next( + ( + entries + for bucket in _request_metadata_buckets(request_data) + if isinstance( + entries := bucket.get("standard_logging_guardrail_information"), + list, + ) + ), + (), + ) + guardrail_information: Final = [ # mutable-ok: response list contract + redact_nested_match_and_regex_keys(entry, keys=_RESPONSE_REDACTED_KEYS) + for entry in recorded + if isinstance(entry, dict) + ] + if isinstance(response, dict): + return response | MappingProxyType({"guardrail_information": guardrail_information}) + if isinstance(response, BaseModel) and response.model_config.get("extra") == "allow": + return response.model_copy(update=MappingProxyType({"guardrail_information": guardrail_information})) + return response + + class CostBreakdownHeaderValues(NamedTuple): original_cost: float | None = None discount_amount: float | None = None @@ -2898,6 +2935,11 @@ class ProxyBaseLLMRequestProcessing: if isinstance(response, dict): response.pop("_hidden_params", None) + if include_guardrail_response_requested(self.data): + response = attach_guardrail_information( # rebind-ok: response tail rebinds the copied response + response=response, request_data=self.data + ) + # Call response headers hook for non-streaming success callback_headers = await proxy_logging_obj.post_call_response_headers_hook( data=self.data, diff --git a/litellm/proxy/common_utils/config_sync_pubsub.py b/litellm/proxy/common_utils/config_sync_pubsub.py index 6d781babe63..b20c0d9c9a5 100644 --- a/litellm/proxy/common_utils/config_sync_pubsub.py +++ b/litellm/proxy/common_utils/config_sync_pubsub.py @@ -55,6 +55,7 @@ _CONFIG_SYNCED_TABLE_NAMES: Final[frozenset[str]] = frozenset( "litellm_ssoconfig", "litellm_cacheconfig", "litellm_configoverrides", + "litellm_uisettings", } ) diff --git a/litellm/proxy/common_utils/debug_utils.py b/litellm/proxy/common_utils/debug_utils.py index dc329e55e31..2544321a1b6 100644 --- a/litellm/proxy/common_utils/debug_utils.py +++ b/litellm/proxy/common_utils/debug_utils.py @@ -8,7 +8,7 @@ import sys import tracemalloc from collections import Counter from collections.abc import Mapping, Sequence -from typing import Any, Final, NamedTuple, Protocol, TypedDict +from typing import Annotated, Any, Final, NamedTuple, Protocol, TypedDict from fastapi import APIRouter, Depends, HTTPException, Query from typing_extensions import ReadOnly @@ -16,8 +16,11 @@ from typing_extensions import ReadOnly from litellm import get_secret_str from litellm._logging import verbose_proxy_logger from litellm.constants import PYTHON_GC_THRESHOLD +from litellm.litellm_core_utils.bug_report import EnvironmentReport from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.bug_report_config import build_proxy_environment_report +from litellm.proxy.common_utils.resource_ownership import is_proxy_admin router: Final = APIRouter() @@ -783,6 +786,23 @@ async def configure_gc_thresholds_endpoint( } +@router.get("/debug/report", include_in_schema=False) +async def get_debug_report( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> EnvironmentReport: + """ + The same LiteLLM-owned environment facts the bug report link puts in a GitHub issue: + versions, deployment kind, and config flags whose keys and values LiteLLM defines. + Nothing from the operator's config values, request data, or errors + + Example usage: + curl http://localhost:4000/debug/report -H "Authorization: Bearer sk-1234" + """ + if not is_proxy_admin(user_api_key_dict): + raise HTTPException(status_code=403, detail="Only proxy admins can read /debug/report") + return build_proxy_environment_report() + + @router.get( "/otel-spans", dependencies=[Depends(user_api_key_auth)], diff --git a/litellm/proxy/common_utils/swagger_utils.py b/litellm/proxy/common_utils/swagger_utils.py index 2609a98a997..83480bf161d 100644 --- a/litellm/proxy/common_utils/swagger_utils.py +++ b/litellm/proxy/common_utils/swagger_utils.py @@ -1,3 +1,4 @@ +import inspect from typing import Any, Final from pydantic import BaseModel, Field @@ -31,11 +32,15 @@ def get_status_code(exception): return 500 # Internal Server Error as default +def _error_description(exception: type[Exception]) -> str: + return inspect.cleandoc(exception.__doc__) if exception.__doc__ else exception.__name__ + + # Create error responses ERROR_RESPONSES: Final = { get_status_code(exception): { "model": ErrorResponse, - "description": exception.__doc__ or exception.__name__, + "description": _error_description(exception), } for exception in LITELLM_EXCEPTION_TYPES } diff --git a/litellm/proxy/config_resolvers/settings_rules.py b/litellm/proxy/config_resolvers/settings_rules.py index f346dd6198d..1f0adfc5248 100644 --- a/litellm/proxy/config_resolvers/settings_rules.py +++ b/litellm/proxy/config_resolvers/settings_rules.py @@ -48,6 +48,7 @@ _UI_SETTINGS_FIELDS: Final[tuple[str, ...]] = ( "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", + "disable_custom_api_keys", "disable_key_generate_for_org_admin", "team_admin_editable_team_fields", ) diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index f75197532b4..5dc0f659a8d 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -1,7 +1,8 @@ import asyncio import json +from collections.abc import Sequence from datetime import datetime, timezone -from typing import Final +from typing import TYPE_CHECKING, Final from pydantic import TypeAdapter @@ -23,6 +24,9 @@ from litellm.proxy._types import ( from litellm.proxy.utils import _hash_token_if_needed from litellm.secret_managers.base_secret_manager import BaseSecretManager +if TYPE_CHECKING: + from prisma import models as prisma_models + # NOTE: This is the prefix for all virtual keys stored in AWS Secrets Manager LITELLM_PREFIX_STORED_VIRTUAL_KEYS: Final = "litellm/" @@ -233,6 +237,19 @@ class KeyManagementEventHooks: Handles the following: - Storing Audit Logs for key deletion """ + KeyManagementEventHooks.create_key_deleted_audit_logs( + keys_being_deleted=keys_being_deleted, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) + await KeyManagementEventHooks._delete_virtual_keys_from_secret_manager(keys_being_deleted=keys_being_deleted) + + @staticmethod + def create_key_deleted_audit_logs( + keys_being_deleted: Sequence["LiteLLM_VerificationToken | prisma_models.LiteLLM_VerificationToken"], + user_api_key_dict: UserAPIKeyAuth, + litellm_changed_by: str | None = None, + ) -> None: from litellm.proxy.management_helpers.audit_logs import ( create_audit_log_for_update, get_audit_log_changed_by, @@ -240,35 +257,33 @@ class KeyManagementEventHooks: ) from litellm.proxy.proxy_server import litellm_proxy_admin_name - # we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes - if is_audit_logging_enabled() and data.keys is not None: - # make an audit log for each key deleted - for key in keys_being_deleted: - if key.token is None: - continue - _key_row = key.model_dump_json(exclude_none=True) + if not is_audit_logging_enabled(): + return + for key in keys_being_deleted: + key_row = LiteLLM_VerificationToken.model_validate(key, from_attributes=True) + if key_row.token is None: + continue + _key_row = key_row.model_dump_json(exclude_none=True) - asyncio.create_task( - create_audit_log_for_update( - request_data=LiteLLM_AuditLogs( - id=str(uuid.uuid4()), - updated_at=datetime.now(timezone.utc), - changed_by=get_audit_log_changed_by( - litellm_changed_by=litellm_changed_by, - user_api_key_dict=user_api_key_dict, - litellm_proxy_admin_name=litellm_proxy_admin_name, - ), - changed_by_api_key=user_api_key_dict.token, - table_name=LitellmTableNames.KEY_TABLE_NAME, - object_id=key.token, - action="deleted", - updated_values="{}", - before_value=_key_row, - ) + asyncio.create_task( + create_audit_log_for_update( + request_data=LiteLLM_AuditLogs( + id=str(uuid.uuid4()), + updated_at=datetime.now(timezone.utc), + changed_by=get_audit_log_changed_by( + litellm_changed_by=litellm_changed_by, + user_api_key_dict=user_api_key_dict, + litellm_proxy_admin_name=litellm_proxy_admin_name, + ), + changed_by_api_key=user_api_key_dict.token, + table_name=LitellmTableNames.KEY_TABLE_NAME, + object_id=key_row.token, + action="deleted", + updated_values="{}", + before_value=_key_row, ) ) - # delete the keys from the secret manager - await KeyManagementEventHooks._delete_virtual_keys_from_secret_manager(keys_being_deleted=keys_being_deleted) + ) @staticmethod async def _store_virtual_key_in_secret_manager(secret_name: str, secret_token: str, team_id: str | None = None): diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py index 9d35178891c..33744906b13 100644 --- a/litellm/proxy/hooks/parallel_request_limiter_v3.py +++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py @@ -63,6 +63,7 @@ from litellm.router_utils.add_retry_fallback_headers import ( ensure_response_additional_headers, response_has_hidden_params, ) +from litellm.router_utils.common_utils import resolve_model_group_alias from litellm.types.caching import RedisPipelineIncrementOperation from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject, ResponseAPIUsage from litellm.types.utils import ( @@ -91,6 +92,26 @@ else: _REQUEST_RATE_LIMIT_DATA: Final = TypeAdapter(Mapping[str, object]) +@dataclass(frozen=True, slots=True) +class RateLimitedModel: + requested: str + group: str + + def limit_in(self, limits: Mapping[str, int] | None) -> int | None: + if limits is None: + return None + requested_limit: Final = limits.get(self.requested) + return requested_limit if requested_limit is not None else limits.get(self.group) + + +def _resolve_model_group_alias_via_proxy_router(model: str) -> str | None: + from litellm.proxy.proxy_server import llm_router + + if llm_router is None: + return None + return resolve_model_group_alias(llm_router.model_group_alias, model) + + def _sibling_counter_keys(window_key: str) -> tuple[str, str]: prefix: Final = window_key.removesuffix(":window") return f"{prefix}:requests", f"{prefix}:tokens" @@ -546,7 +567,7 @@ class RequestRateLimiterStash: parallel_slot: ParallelSlotAcquisition | None = None parallel_slot_release_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False, compare=False) reserved_tokens: int = 0 - reserved_model: str | None = None + reserved_model: RateLimitedModel | None = None reserved_scopes: frozenset[tuple[str, str]] = field(default_factory=frozenset) itpm_reserved_tokens: int = 0 itpm_reserved_scopes: frozenset[tuple[str, str]] = field(default_factory=frozenset) @@ -626,9 +647,11 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): self, internal_usage_cache: InternalUsageCache, time_provider: Callable[[], datetime] | None = None, + model_group_resolver: Callable[[str], str | None] = _resolve_model_group_alias_via_proxy_router, ): self.internal_usage_cache = internal_usage_cache self._time_provider = time_provider or datetime.now + self._model_group_resolver = model_group_resolver if self.internal_usage_cache.dual_cache.redis_cache is not None: self.batch_rate_limiter_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script( BATCH_RATE_LIMITER_SCRIPT @@ -2346,6 +2369,14 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): if response["overall_code"] == "OVER_LIMIT": self._handle_rate_limit_error(response, descriptors, requested_model) + def _rate_limited_model(self, requested_model: str | None) -> RateLimitedModel | None: + if not requested_model: + return None + return RateLimitedModel( + requested=requested_model, + group=self._model_group_resolver(requested_model) or requested_model, + ) + def create_organization_rate_limit_descriptor( self, user_api_key_dict: UserAPIKeyAuth, requested_model: str | None = None ) -> list[RateLimitDescriptor]: @@ -2367,43 +2398,28 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): ) ) - # Model specific org rate limits - if ( + model: Final = self._rate_limited_model(requested_model) + if model is None: + return descriptors + model_specific_tpm_limit: Final = model.limit_in( + get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_tpm_limit") + ) + model_specific_rpm_limit: Final = model.limit_in( get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_rpm_limit") - is not None - or get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_tpm_limit") - is not None - ): - _tpm_limit_for_team_model: Final = ( - get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_tpm_limit") or {} + ) + if model_specific_tpm_limit is None and model_specific_rpm_limit is None: + return descriptors + descriptors.append( + RateLimitDescriptor( + key="model_per_organization", + value=f"{user_api_key_dict.org_id}:{model.group}", + rate_limit={ + "requests_per_unit": model_specific_rpm_limit, + "tokens_per_unit": model_specific_tpm_limit, + "window_size": self.window_size, + }, ) - _rpm_limit_for_team_model: Final = ( - get_model_rate_limit_from_metadata(user_api_key_dict, "organization_metadata", "model_rpm_limit") or {} - ) - - should_check_rate_limit = False - if requested_model in _tpm_limit_for_team_model or requested_model in _rpm_limit_for_team_model: - should_check_rate_limit = True - - if should_check_rate_limit: - model_specific_tpm_limit = None - model_specific_rpm_limit = None - if requested_model in _tpm_limit_for_team_model: - model_specific_tpm_limit = _tpm_limit_for_team_model[requested_model] - if requested_model in _rpm_limit_for_team_model: - model_specific_rpm_limit = _rpm_limit_for_team_model[requested_model] - descriptors.append( - RateLimitDescriptor( - key="model_per_organization", - value=f"{user_api_key_dict.org_id}:{requested_model}", - rate_limit={ - "requests_per_unit": model_specific_rpm_limit, - "tokens_per_unit": model_specific_tpm_limit, - "window_size": self.window_size, - }, - ) - ) - + ) return descriptors def _add_model_per_key_rate_limit_descriptor( @@ -2425,34 +2441,22 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): get_key_model_tpm_limit, ) - if not requested_model: + model: Final = self._rate_limited_model(requested_model) + if model is None: return - - _tpm_limit_for_key_model = get_key_model_tpm_limit(user_api_key_dict, model_name=requested_model) - _rpm_limit_for_key_model = get_key_model_rpm_limit(user_api_key_dict, model_name=requested_model) - - if _tpm_limit_for_key_model is None and _rpm_limit_for_key_model is None: - return - - _tpm_limit_for_key_model = _tpm_limit_for_key_model or {} - _rpm_limit_for_key_model = _rpm_limit_for_key_model or {} - - # Check if model has any rate limits configured - should_check_rate_limit: Final = ( - requested_model in _tpm_limit_for_key_model or requested_model in _rpm_limit_for_key_model + model_specific_tpm_limit: Final = model.limit_in( + get_key_model_tpm_limit(user_api_key_dict, model_name=model.group) ) - - if not should_check_rate_limit: + model_specific_rpm_limit: Final = model.limit_in( + get_key_model_rpm_limit(user_api_key_dict, model_name=model.group) + ) + if model_specific_tpm_limit is None and model_specific_rpm_limit is None: return - # Get model-specific limits - model_specific_tpm_limit: Final[int | None] = _tpm_limit_for_key_model.get(requested_model) - model_specific_rpm_limit: Final[int | None] = _rpm_limit_for_key_model.get(requested_model) - descriptors.append( RateLimitDescriptor( key="model_per_key", - value=f"{user_api_key_dict.api_key}:{requested_model}", + value=f"{user_api_key_dict.api_key}:{model.group}", rate_limit={ "requests_per_unit": model_specific_rpm_limit, "tokens_per_unit": model_specific_tpm_limit, @@ -2955,32 +2959,30 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): def _key_owns_model_limit( self, user_api_key_dict: UserAPIKeyAuth, - requested_model: str, + model: RateLimitedModel, rate_limit_key: Literal["model_rpm_limit", "model_tpm_limit"], ) -> bool: - key_own_limits: Final = get_key_own_model_rate_limit(user_api_key_dict, rate_limit_key) - return key_own_limits is not None and key_own_limits.get(requested_model) is not None + return model.limit_in(get_key_own_model_rate_limit(user_api_key_dict, rate_limit_key)) is not None def _inherited_team_model_limit( self, user_api_key_dict: UserAPIKeyAuth, - requested_model: str, + model: RateLimitedModel, rate_limit_key: Literal["model_rpm_limit", "model_tpm_limit"], ) -> int | None: - team_limits: Final = get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", rate_limit_key) - team_limit: Final = team_limits.get(requested_model) if team_limits else None - if team_limit is None: - return None - if self._key_owns_model_limit(user_api_key_dict, requested_model, rate_limit_key): + team_limit: Final = model.limit_in( + get_model_rate_limit_from_metadata(user_api_key_dict, "team_metadata", rate_limit_key) + ) + if team_limit is None or self._key_owns_model_limit(user_api_key_dict, model, rate_limit_key): return None return team_limit def _key_owns_model_tpm_limit_from_request_metadata( self, request_metadata: Mapping[str, object], - model_group: str | None, + model: RateLimitedModel | None, ) -> bool: - if model_group is None: + if model is None: return False key_view: Final = UserAPIKeyAuth.model_validate( { @@ -2988,7 +2990,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): "model_max_budget": request_metadata.get("user_api_key_model_max_budget") or {}, } ) - return self._key_owns_model_limit(key_view, model_group, "model_tpm_limit") + return self._key_owns_model_limit(key_view, model, "model_tpm_limit") def _add_team_model_rate_limit_descriptor_from_metadata( self, @@ -2996,16 +2998,17 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): requested_model: str | None, descriptors: list[RateLimitDescriptor], ) -> None: - if requested_model is None: + model: Final = self._rate_limited_model(requested_model) + if model is None: return - team_rpm_limit: Final = self._inherited_team_model_limit(user_api_key_dict, requested_model, "model_rpm_limit") - team_tpm_limit: Final = self._inherited_team_model_limit(user_api_key_dict, requested_model, "model_tpm_limit") + team_rpm_limit: Final = self._inherited_team_model_limit(user_api_key_dict, model, "model_rpm_limit") + team_tpm_limit: Final = self._inherited_team_model_limit(user_api_key_dict, model, "model_tpm_limit") if team_rpm_limit is None and team_tpm_limit is None: return descriptors.append( RateLimitDescriptor( key="model_per_team", - value=f"{user_api_key_dict.team_id}:{requested_model}", + value=f"{user_api_key_dict.team_id}:{model.group}", rate_limit={ "requests_per_unit": team_rpm_limit, "tokens_per_unit": team_tpm_limit, @@ -3021,34 +3024,28 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): descriptors: list[RateLimitDescriptor], ) -> None: """Add project model rate limit descriptor from project_metadata if applicable.""" - if ( - get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_rpm_limit") is not None - or get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_tpm_limit") is not None - ): - _tpm_limit_for_project_model: Final = ( - get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_tpm_limit") or {} + model: Final = self._rate_limited_model(requested_model) + if model is None: + return + model_specific_tpm_limit: Final = model.limit_in( + get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_tpm_limit") + ) + model_specific_rpm_limit: Final = model.limit_in( + get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_rpm_limit") + ) + if model_specific_tpm_limit is None and model_specific_rpm_limit is None: + return + descriptors.append( + RateLimitDescriptor( + key="model_per_project", + value=f"{user_api_key_dict.project_id}:{model.group}", + rate_limit={ + "requests_per_unit": model_specific_rpm_limit, + "tokens_per_unit": model_specific_tpm_limit, + "window_size": self.window_size, + }, ) - _rpm_limit_for_project_model: Final = ( - get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_rpm_limit") or {} - ) - should_check_rate_limit: Final = ( - requested_model in _tpm_limit_for_project_model or requested_model in _rpm_limit_for_project_model - ) - - if should_check_rate_limit and requested_model is not None: - model_specific_tpm_limit: Final = _tpm_limit_for_project_model.get(requested_model) - model_specific_rpm_limit: Final = _rpm_limit_for_project_model.get(requested_model) - descriptors.append( - RateLimitDescriptor( - key="model_per_project", - value=f"{user_api_key_dict.project_id}:{requested_model}", - rate_limit={ - "requests_per_unit": model_specific_rpm_limit, - "tokens_per_unit": model_specific_tpm_limit, - "window_size": self.window_size, - }, - ) - ) + ) def add_project_io_token_rate_limit_descriptors_from_metadata( self, @@ -3062,25 +3059,21 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): TPM descriptor above -- these give Bedrock Mantle-style separate input/output token quotas at the project level. """ - if requested_model is None or user_api_key_dict.project_id is None: + model: Final = self._rate_limited_model(requested_model) + if model is None or user_api_key_dict.project_id is None: return - itpm_limit_for_project_model: Final = ( + model_itpm_limit: Final = model.limit_in( get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_itpm_limit") - or {} # mutable-ok: metadata helper returns an optional mapping ) - otpm_limit_for_project_model: Final = ( + model_otpm_limit: Final = model.limit_in( get_model_rate_limit_from_metadata(user_api_key_dict, "project_metadata", "model_otpm_limit") - or {} # mutable-ok: metadata helper returns an optional mapping ) - model_itpm_limit: Final = itpm_limit_for_project_model.get(requested_model) - model_otpm_limit: Final = otpm_limit_for_project_model.get(requested_model) - if model_itpm_limit is None and model_otpm_limit is None: return - descriptor_value: Final = f"{user_api_key_dict.project_id}:{requested_model}" + descriptor_value: Final = f"{user_api_key_dict.project_id}:{model.group}" if model_itpm_limit is not None: descriptors.append( RateLimitDescriptor( @@ -3767,7 +3760,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): # the (actual - reserved) delta to those — unreserved # scopes get charged the full actual usage instead. stash.reserved_tokens = estimated_tokens - stash.reserved_model = requested_model + stash.reserved_model = self._rate_limited_model(requested_model) stash.reserved_scopes = frozenset( (d["key"], d["value"]) for d in descriptors @@ -4514,9 +4507,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): reserved_scopes: Final[frozenset[tuple[str, str]]] = stash.reserved_scopes if stash is not None else frozenset() # Reconciliation must target the same model-scoped counter that the # pre-call reservation incremented. If a reservation was made, - # ``reserved_model`` is authoritative; otherwise fall back to the - # router's ``model_group`` (covers the no-reservation charge path). - reconcile_model: Final = reserved_model or model_group + # ``reserved_model`` (resolved at admission, so an alias map reload + # mid-flight cannot move the charge) is authoritative; otherwise fall + # back to the router's ``model_group`` (the no-reservation charge path). + reconcile_model: Final = reserved_model if reserved_model is not None else self._rate_limited_model(model_group) pipeline_operations: Final[list[RedisPipelineIncrementOperation]] = [] @@ -4534,7 +4528,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger): targets: Final = self._collect_tpm_scope_targets( standard_logging_metadata=standard_logging_metadata, kwargs=kwargs, - model_group=reconcile_model, + model_group=reconcile_model.group if reconcile_model is not None else None, ) charged_targets: Final = ( [target for target in targets if target[0] != "model_per_team"] diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 44d45dcd687..4d5b91be034 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -3116,7 +3116,15 @@ async def move_guardrails_to_metadata( - If guardrails not set on API key, then checks request metadata - Adds guardrails from policies attached to key/team metadata - Adds guardrails from policy engine based on team/key/model context + - Moves include_guardrail_response into request metadata before provider dispatch """ + if "include_guardrail_response" in data: + data[_metadata_variable_name][ + "include_guardrail_response" + ] = ( # rebind-ok: pre-call hooks mutate the shared request dict in place + data.pop("include_guardrail_response") is True + ) + # Early-out: skip all guardrails processing when nothing is configured key_metadata: Final = user_api_key_dict.metadata team_metadata: Final = user_api_key_dict.team_metadata diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 6509977f7ff..133181e9203 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -48,6 +48,7 @@ from litellm.proxy.common_utils.user_api_key_cache import ( user_object_permission_id_cache_key, ) from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler +from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks from litellm.proxy.hooks.model_max_budget_limiter import build_model_max_budget_usage from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks from litellm.proxy.management_endpoints.common_daily_activity import ( @@ -2539,6 +2540,12 @@ async def delete_user( prisma_client=prisma_client, ) await _verification_token_table(prisma_client).delete_many(where=key_filter) + if keys_to_delete: + KeyManagementEventHooks.create_key_deleted_audit_logs( + keys_being_deleted=keys_to_delete, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) await delete_cache_key_objects( hashed_tokens=hashed_tokens_to_delete, user_api_key_cache=user_api_key_cache, diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index fbc7cf18003..801c45501ce 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -118,9 +118,6 @@ from litellm.proxy.management_helpers.team_member_permission_checks import ( from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key -from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( - get_ui_settings_cached, -) from litellm.proxy.utils import ( PrismaClient, ProxyLogging, @@ -487,8 +484,10 @@ async def _check_custom_key_allowed(custom_key_value: str | None) -> None: if custom_key_value is None: return - ui_settings: Final = await get_ui_settings_cached() - if ui_settings.get("disable_custom_api_keys", False) is True: + from litellm.proxy.config_resolvers.settings_rules import coerce_bool + from litellm.proxy.proxy_server import general_settings + + if coerce_bool(general_settings.get("disable_custom_api_keys", False)) is True: verbose_proxy_logger.warning("Custom API key rejected: disable_custom_api_keys is enabled") raise HTTPException( status_code=403, diff --git a/litellm/proxy/management_endpoints/router_settings_endpoints.py b/litellm/proxy/management_endpoints/router_settings_endpoints.py index d6d74ada35a..a557e3a6082 100644 --- a/litellm/proxy/management_endpoints/router_settings_endpoints.py +++ b/litellm/proxy/management_endpoints/router_settings_endpoints.py @@ -33,6 +33,9 @@ class RouterSettingsResponse(BaseModel): fields: list[RouterSettingsField] = Field(description="List of all configurable router settings with metadata") current_values: dict[str, Any] = Field(description="Current values of router settings") routing_strategy_descriptions: dict[str, str] = Field(description="Descriptions for each routing strategy option") + routing_group_strategies: tuple[str, ...] = Field( + description="Strategies supported when constructing a routing group" + ) source: dict[str, FieldSource] = Field(description="Source of each current router setting") @@ -41,6 +44,9 @@ class RouterFieldsResponse(BaseModel): description="List of all configurable router settings with metadata (without field values)" ) routing_strategy_descriptions: dict[str, str] = Field(description="Descriptions for each routing strategy option") + routing_group_strategies: tuple[str, ...] = Field( + description="Strategies supported when constructing a routing group" + ) def _router_setting_source( @@ -114,7 +120,10 @@ async def get_router_settings( if llm_router is not None: # Router exposes routing groups as private `_routing_groups`; the # generic `hasattr` loop below would miss them. - current_values["routing_groups"] = [group.model_dump() for group in llm_router._routing_groups.values()] + current_values["routing_groups"] = [ + group.model_dump(exclude=frozenset(("model_priorities",)) if group.model_priorities is None else None) + for group in llm_router._routing_groups.values() + ] for field in router_fields: if field.field_name == "routing_groups": continue @@ -147,6 +156,7 @@ async def get_router_settings( fields=router_fields, current_values=current_values, routing_strategy_descriptions=ROUTING_STRATEGY_DESCRIPTIONS, + routing_group_strategies=(*available_routing_strategies, "priority"), source=source, ) except Exception as e: @@ -196,6 +206,7 @@ async def get_router_fields( return RouterFieldsResponse( fields=router_fields, routing_strategy_descriptions=ROUTING_STRATEGY_DESCRIPTIONS, + routing_group_strategies=(*available_routing_strategies, "priority"), ) except Exception as e: verbose_proxy_logger.error("Error fetching router fields: %s", e) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0a142166bc5..d092fc2fbb7 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -118,6 +118,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_ from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks from litellm.proxy.hooks.model_max_budget_limiter import ( build_model_max_budget_usage, resolve_model_budget, @@ -3751,6 +3752,13 @@ async def _team_member_delete( } ) + if keys_to_delete: + KeyManagementEventHooks.create_key_deleted_audit_logs( + keys_being_deleted=keys_to_delete, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) + await delete_cache_team_object( team_id=data.team_id, team_alias=existing_team_row.team_alias, @@ -4469,6 +4477,13 @@ async def delete_team( await prisma_client.delete_data(team_id_list=data.team_ids, table_name="key") + if keys_to_delete: + KeyManagementEventHooks.create_key_deleted_audit_logs( + keys_being_deleted=keys_to_delete, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) + await _invalidate_deleted_key_cache( keys=keys_to_delete, user_api_key_cache=user_api_key_cache, diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index af51a194413..8b5b601fe8a 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -31,6 +31,7 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import delete_cache_key_objects, get_jwt_key_mapping_cache_keys_for_tokens from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem from litellm.proxy.management_endpoints.common_utils import ( @@ -93,20 +94,28 @@ class _TeamRemoval: team: LiteLLM_TeamTable removed: frozenset[str] matched: frozenset[int] - deleted_key_tokens: tuple[str, ...] + deleted_keys: tuple["prisma_models.LiteLLM_VerificationToken", ...] jwt_mapping_cache_keys: tuple[str, ...] + @property + def deleted_key_tokens(self) -> tuple[str, ...]: + return tuple(k.token for k in self.deleted_keys) + @dataclass(frozen=True, slots=True) class _UserBatchDeletion: removals: Mapping[str, _TeamRemoval] - deleted_key_tokens: tuple[str, ...] + deleted_keys: tuple["prisma_models.LiteLLM_VerificationToken", ...] jwt_mapping_cache_keys: tuple[str, ...] + @property + def deleted_key_tokens(self) -> tuple[str, ...]: + return tuple(k.token for k in self.deleted_keys) + @dataclass(frozen=True, slots=True) class _DeletedKeys: - tokens: tuple[str, ...] + keys: tuple["prisma_models.LiteLLM_VerificationToken", ...] jwt_mapping_cache_keys: tuple[str, ...] @@ -276,7 +285,7 @@ async def _remove_members_from_team( ), removed=cleanup_ids, matched=matched, - deleted_key_tokens=tuple(k.token for k in keys), + deleted_keys=tuple(keys), jwt_mapping_cache_keys=jwt_mapping_cache_keys, ) @@ -330,6 +339,12 @@ async def bulk_remove_team_members( members: Final = tuple(data.members[i] for i in kept_indexes) async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx: removal: Final = await _remove_members_from_team(prisma_client, tx, team_id, members, user_api_key_dict) + if removal.deleted_keys: + KeyManagementEventHooks.create_key_deleted_audit_logs( + keys_being_deleted=removal.deleted_keys, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=None, + ) await delete_cache_key_objects( hashed_tokens=removal.deleted_key_tokens, user_api_key_cache=user_api_key_cache, @@ -407,7 +422,7 @@ async def _delete_user_rows( await _org_membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids)) await _membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids)) await _user_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids)) - return _DeletedKeys(tokens=tuple(k.token for k in keys), jwt_mapping_cache_keys=jwt_mapping_cache_keys) + return _DeletedKeys(keys=tuple(keys), jwt_mapping_cache_keys=jwt_mapping_cache_keys) async def _delete_users_tx( @@ -446,7 +461,7 @@ async def _delete_users_tx( ) return _UserBatchDeletion( removals=removals, - deleted_key_tokens=deleted_keys.tokens + tuple(t for r in removals.values() for t in r.deleted_key_tokens), + deleted_keys=deleted_keys.keys + tuple(k for r in removals.values() for k in r.deleted_keys), jwt_mapping_cache_keys=deleted_keys.jwt_mapping_cache_keys + tuple(k for r in removals.values() for k in r.jwt_mapping_cache_keys), ) @@ -469,6 +484,12 @@ async def _delete_users( except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure verbose_proxy_logger.error("users/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e) return _error_message(e) + if deletion.deleted_keys: + KeyManagementEventHooks.create_key_deleted_audit_logs( + keys_being_deleted=deletion.deleted_keys, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) await delete_cache_key_objects( hashed_tokens=deletion.deleted_key_tokens, user_api_key_cache=user_api_key_cache, @@ -555,7 +576,7 @@ async def bulk_delete_users( litellm_changed_by, ) if candidates - else _UserBatchDeletion(removals=MappingProxyType({}), deleted_key_tokens=(), jwt_mapping_cache_keys=()) + else _UserBatchDeletion(removals=MappingProxyType({}), deleted_keys=(), jwt_mapping_cache_keys=()) ) def result(index: int, user_id: str) -> UserDeleteResult: diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index b1960b9a046..2d071343844 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -53,7 +53,7 @@ from litellm.llms.deepgram.common_utils import ( deepgram_listen_requested_model, deepgram_listen_websocket_target, ) -from litellm.llms.fal_ai.cost_calculator import fal_ai_queue_base +from litellm.llms.fal_ai.cost_calculator import fal_ai_passthrough_cost, fal_ai_queue_base from litellm.llms.nvidia_nim.passthrough.transformation import nvidia_nim_model_group_in_path from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.passthrough.main import AsyncPassthroughStreamingResponse @@ -460,13 +460,11 @@ async def fal_ai_proxy_route( status_code=401, detail="FAL_AI_API_KEY is not set and no fal_ai pass-through deployment credentials are configured", ) - if "/requests/" not in endpoint: - priced_model: Final = f"fal_ai/{endpoint}" - if priced_model not in (litellm.model_cost or {}): - raise HTTPException( - status_code=400, - detail=f"{priced_model} has no pricing entry; only priced Fal endpoints can be submitted through /fal_ai", - ) + if "/requests/" not in endpoint and fal_ai_passthrough_cost(endpoint, await _read_request_body(request)) is None: + raise HTTPException( + status_code=400, + detail=f"fal_ai/{endpoint} has no pricing entry for this request; only priced Fal requests can be submitted through /fal_ai", + ) endpoint_func: Final = create_pass_through_route( endpoint=endpoint, target=str(updated_url), diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 78885461724..40140974198 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -1283,6 +1283,7 @@ def run_server( if os.getenv("DATABASE_URL", None) is not None or os.getenv("DIRECT_URL", None) is not None: from litellm.proxy.db.db_url_settings import ( + DISABLE_PREPARED_STATEMENTS_ENV_VAR, add_missing_query_params, idle_lifetime_params, reader_shareable_params, @@ -1305,12 +1306,16 @@ def run_server( sys.exit(1) from litellm.secret_managers.main import get_secret + env_disable_prepared_statements: Final = token_auth_flag_enabled( + os.getenv(DISABLE_PREPARED_STATEMENTS_ENV_VAR), env_var=DISABLE_PREPARED_STATEMENTS_ENV_VAR + ) + disable_prepared_statements: Final = db_disable_prepared_statements or env_disable_prepared_statements connection_url_params: Final = _build_db_connection_url_params( connection_limit=db_connection_pool_limit, pool_timeout=db_connection_timeout, connect_timeout=db_connect_timeout, socket_timeout=db_socket_timeout, - disable_prepared_statements=db_disable_prepared_statements, + disable_prepared_statements=disable_prepared_statements, extra_params=db_extra_connection_params, ) lifetime_params: Final = idle_lifetime_params(general_settings.get("database_max_idle_connection_lifetime")) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8e2bb3676d9..82347e6268d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -132,6 +132,7 @@ from litellm.proxy.common_utils.callback_utils import ( strip_callback_config, ) from litellm.proxy.common_utils.realtime_utils import _realtime_request_body +from litellm.router_utils.access_windows import access_windows_config_error from litellm.router_utils.add_retry_fallback_headers import ( get_fallback_errors_from_headers, get_hidden_params_dict, @@ -309,6 +310,7 @@ from litellm.litellm_core_utils.core_helpers import ( from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.realtime_errors import ( + close_after_upstream_handshake_refusal, realtime_error_event, websocket_close_reason, ) @@ -4852,6 +4854,22 @@ def validate_deployment_complexity_router_placement(model: Mapping[str, object]) raise ValueError(f"model {model.get('model_name', '')!r}: {violation}") +def validate_deployment_access_windows(model: Mapping[str, object]) -> None: + """ + Reject a malformed `model_info.access_windows` instead of silently dropping the deployment. + + Checked here rather than on `ModelInfo` because the proxy builds its router with + `ignore_invalid_deployments=True`, so a rejection further down turns a bad + deployment into a silently missing model instead of a refusal to start. + """ + model_info: Final = model.get("model_info") + if not isinstance(model_info, Mapping): + return + error: Final = access_windows_config_error(model_info, model_name=str(model.get("model_name", ""))) + if error is not None: + raise ValueError(error) + + def validate_auto_router_capability_limits(model_list: Sequence[Mapping[str, object]], *, limit: int | None) -> None: """ Refuse to start when config.yaml defines more auto-routers claiming a licensed capability than allowed. @@ -6552,6 +6570,7 @@ class ProxyConfig: model["litellm_params"][k] = get_secret(v) validate_deployment_max_agentic_loops(model) validate_deployment_complexity_router_placement(model) + validate_deployment_access_windows(model) pin_complexity_router_model_id(model) complexity_router_config = model["litellm_params"].get("complexity_router_config") if isinstance(complexity_router_config, dict): @@ -12513,9 +12532,9 @@ async def realtime_websocket_endpoint( user_model=user_model, ) await llm_call - except websockets.exceptions.InvalidStatusCode as e: + except websockets.exceptions.InvalidStatus as e: verbose_proxy_logger.exception("Invalid status code") - await websocket.close(code=e.status_code, reason="Invalid status code") + await close_after_upstream_handshake_refusal(websocket, e.response.status_code) except Exception as e: verbose_proxy_logger.exception("Internal server error") redacted_error: Final = _redact_string(str(e)) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index a319535f725..b5980f9b224 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -69,6 +69,18 @@ _SESSION_KEY_EXPR: Final = "COALESCE(NULLIF(session_id, ''), request_id)" _SESSION_GROUP_KEY_SQL: Final = f"{_SESSION_KEY_EXPR}, api_key" _MCP_CALL_TYPES_SQL: Final = "('call_mcp_tool', 'list_mcp_tools')" _AGENT_CALL_TYPE_SQL: Final = "'asend_message'" +_BATCH_CALL_TYPES_SQL: Final = "('acreate_batch', 'create_batch', 'aretrieve_batch', 'retrieve_batch')" +_SPAN_TYPE_SQL_CONDITIONS: Final[Mapping[str, str]] = MappingProxyType( + { + "mcp": f"call_type IN {_MCP_CALL_TYPES_SQL}", + "agent": f"call_type = {_AGENT_CALL_TYPE_SQL}", + "batch": f"call_type IN {_BATCH_CALL_TYPES_SQL}", + "llm": ( + f"(call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} " + f"AND call_type NOT IN {_BATCH_CALL_TYPES_SQL})" + ), + } +) _SPEND_LOG_LIST_COLUMNS: Final = """ request_id, call_type, api_key, spend, total_tokens, prompt_tokens, completion_tokens, "startTime", "endTime", @@ -2410,6 +2422,10 @@ async def ui_view_spend_logs( default=None, description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state", ), + span_type: str | None = fastapi.Query( + default=None, + description="Filter logs by span type: llm, agent, mcp, or batch", + ), model: str | None = fastapi.Query(default=None, description="Filter logs by model"), model_id: str | None = fastapi.Query( default=None, @@ -2512,6 +2528,13 @@ async def ui_view_spend_logs( param="cache_hit_filter", code=status.HTTP_400_BAD_REQUEST, ) + if isinstance(span_type, str) and span_type not in _SPAN_TYPE_SQL_CONDITIONS: + raise ProxyException( + message=f"Invalid span_type: {span_type}. Must be one of: llm, agent, mcp, batch", + type="bad_request", + param="span_type", + code=status.HTTP_400_BAD_REQUEST, + ) try: is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) @@ -2776,6 +2799,10 @@ async def ui_view_spend_logs( elif cache_hit_filter == "miss": sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')") + span_type_condition: Final = _span_type_sql_condition(span_type) + if span_type_condition is not None: + sql_conditions.append(span_type_condition) + if exclude_internal_health_checks: sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})") sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS) @@ -4673,6 +4700,12 @@ def _build_status_filter_condition(status_filter: str | None) -> Mapping[str, ob return {"status": {"equals": status_filter}} +def _span_type_sql_condition(span_type: str | None) -> str | None: + if span_type is None: + return None + return _SPAN_TYPE_SQL_CONDITIONS.get(span_type) + + def _is_admin_view_safe(user_api_key_dict: UserAPIKeyAuth) -> bool: """ Safely determine if the current user has admin view permissions. diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index f37b82f3b54..d520177965c 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -407,6 +407,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [ "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", + "disable_custom_api_keys", "disable_key_generate_for_org_admin", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, ] diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index ea3ecb7f637..c7ce9d081d6 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -52,6 +52,7 @@ from litellm.constants import ( DEFAULT_MODEL_CREATED_AT_TIME, LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL, MAX_TEAM_LIST_LIMIT, + PROXY_REJECTED_BEFORE_ROUTING_KEY, REDIS_SPEND_LOGS_BUFFER_DEQUEUE_COUNT, SPEND_LOG_QUEUE_MAX_BYTES, SPEND_LOG_WRITE_BATCH_MAX_BYTES, @@ -979,6 +980,92 @@ def _failure_usage_to_lift( _EMPTY_LIFT: Final = MappingProxyType({}) +def _stamp_deployment_attribution( + litellm_params: dict[str, object], model_group: str | None, team_id: str | None, dispatched: bool +) -> Mapping[str, object]: + """Stamp provider and logging-metadata attribution onto ``litellm_params`` and return it. + ``litellm_params["model_info"]`` stays unset: the router's cooldown and per-deployment rpm + callbacks key off it and must not count a proxy-side reject against the deployment. A failure + after the provider handoff keeps the metadata the router stamped; a request that never reached a + provider is flagged ``PROXY_REJECTED_BEFORE_ROUTING_KEY`` (deployment metrics key off it) whatever + its metadata says, since ``metadata.model_info`` can be caller supplied.""" + attribution: Final = _deployment_attribution_for_model_group(model_group, team_id) + if "custom_llm_provider" in attribution: + litellm_params["custom_llm_provider"] = attribution["custom_llm_provider"] + if dispatched: + return attribution + litellm_params[PROXY_REJECTED_BEFORE_ROUTING_KEY] = True + if "model_info" not in attribution: + return attribution + if litellm_params.get("metadata") is None: + litellm_params["metadata"] = {} # mutable-ok: legacy logging payload is populated in place + metadata: Final = litellm_params["metadata"] + if not isinstance(metadata, dict): + return attribution + metadata.setdefault("model_info", attribution["model_info"]) + metadata.setdefault("deployment", attribution["deployment"]) + return attribution + + +def _deployment_attribution_for_model_group(model_group: object, team_id: str | None) -> Mapping[str, object]: + """Provider fields the router would have stamped had it reached a deployment: + ``custom_llm_provider`` when every deployment in the group resolves to the same + provider, plus ``model_info`` and ``deployment`` when the group has exactly one. + ``team_id`` picks the key's team deployments over a global group of the same public name.""" + if not isinstance(model_group, str): + return _EMPTY_LIFT + + from litellm.proxy.proxy_server import llm_router + + if llm_router is None: + return _EMPTY_LIFT + deployments: Final = llm_router.get_model_list(model_name=model_group, team_id=team_id) + if not deployments: + return _EMPTY_LIFT + + def _provider_for_deployment(deployment: Mapping[str, object]) -> str | None: + litellm_params: Final = cast( # cast-ok: router deployment parameters are mapping-shaped + Mapping[str, object], deployment["litellm_params"] + ) + try: + provider: Final = litellm.get_llm_provider( + model=cast(str, litellm_params["model"]), # cast-ok: router deployment model is a string + custom_llm_provider=cast( # cast-ok: router deployment provider is optional + str | None, litellm_params.get("custom_llm_provider") + ), + )[1] + return cast(str | None, provider) # cast-ok: provider resolver returns an optional provider string + except Exception: # noqa: BLE001 # get_llm_provider raises for unmapped models + return None + + providers: Final = frozenset(_provider_for_deployment(deployment) for deployment in deployments) + shared_provider: Final = next(iter(providers)) if len(providers) == 1 else None + single_deployment: Final = deployments[0] if len(deployments) == 1 else None + single_deployment_params: Final = ( + cast( # cast-ok: router deployment parameters are mapping-shaped + Mapping[str, object], single_deployment["litellm_params"] + ) + if single_deployment is not None + else None + ) + return MappingProxyType( + { + # mutable-ok: frozen immediately by the outer MappingProxyType + **({"custom_llm_provider": shared_provider} if shared_provider is not None else {}), + **( + { # mutable-ok: frozen immediately by the outer MappingProxyType + "model_info": dict( # mutable-ok: preserve the router's mutable model-info payload + single_deployment.get("model_info") or {} + ), + "deployment": single_deployment_params["model"], + } + if single_deployment is not None and single_deployment_params is not None + else {} # mutable-ok: frozen immediately by the outer MappingProxyType + ), + } + ) + + def _call_type_for_route(route: str | None) -> str | None: """The route's call type when it maps to a single operation (its async and sync variants); None for routes shared by several operations, since the method is not known here.""" @@ -3227,11 +3314,25 @@ class ProxyLogging: elif k not in ("model", "user", "litellm_logging_obj"): _optional_params[k] = v + attribution: Final = _stamp_deployment_attribution( + _litellm_params, + request_data.get("model"), + user_api_key_dict.team_id, + dispatched=litellm_logging_obj.model_call_details.get("first_api_call_start_time") is not None, + ) + litellm_logging_obj.update_environment_variables( model=request_data.get("model", ""), user=request_data.get("user", ""), optional_params=_optional_params, litellm_params=_litellm_params, + **( + { # mutable-ok: frozen immediately by keyword expansion + "custom_llm_provider": attribution["custom_llm_provider"] + } + if "custom_llm_provider" in attribution + else {} # mutable-ok: frozen immediately by keyword expansion + ), ) input: list | str | dict = "" diff --git a/litellm/responses/main.py b/litellm/responses/main.py index c5032536df4..5c4c9ea3987 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -26,6 +26,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( update_responses_input_with_model_file_ids, update_responses_tools_with_model_file_ids, ) +from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig @@ -1218,6 +1219,27 @@ def responses( # get llm provider logic litellm_params: Final = GenericLiteLLMParams(**kwargs) + try: + effective_extra_headers: Final = ( + add_provider_affinity_header( + headers=extra_headers or MappingProxyType({}), + litellm_params=MappingProxyType( + { + "provider_affinity_header": litellm_params.provider_affinity_header, + "litellm_session_id": kwargs.get("litellm_session_id"), + "session_id": kwargs.get("session_id"), + "metadata": metadata, + "litellm_metadata": kwargs.get("litellm_metadata"), + } + ), + ) + if litellm_params.provider_affinity_header is not None + else extra_headers + ) + except ValueError as affinity_error: + raise litellm.BadRequestError( + message=str(affinity_error), model=model, llm_provider=custom_llm_provider + ) from affinity_error ######################################################### # MOCK RESPONSE LOGIC @@ -1261,7 +1283,7 @@ def responses( top_p=top_p, truncation=truncation, user=user, - extra_headers=extra_headers, + extra_headers=effective_extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, @@ -1332,7 +1354,7 @@ def responses( safety_identifier=safety_identifier, text_format=text_format, allowed_openai_params=allowed_openai_params, - extra_headers=extra_headers, + extra_headers=effective_extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, @@ -1352,7 +1374,7 @@ def responses( custom_llm_provider=custom_llm_provider, _is_async=_is_async, stream=stream, - extra_headers=extra_headers, + extra_headers=effective_extra_headers, extra_body=extra_body, timeout=timeout if timeout is not None else request_timeout, allowed_openai_params=allowed_openai_params, @@ -1381,6 +1403,7 @@ def responses( "model_info": kwargs.get("model_info"), "data_residency": infer_openai_data_residency(custom_llm_provider, litellm_params.api_base), "metadata": (kwargs["litellm_metadata"] if "litellm_metadata" in kwargs else kwargs.get("metadata")), + "provider_affinity_header": litellm_params.provider_affinity_header, }, custom_llm_provider=custom_llm_provider, ) @@ -1400,7 +1423,7 @@ def responses( custom_llm_provider=custom_llm_provider, litellm_params=litellm_params, logging_obj=litellm_logging_obj, - extra_headers=extra_headers, + extra_headers=effective_extra_headers, extra_body=extra_body, timeout=timeout or request_timeout, _is_async=_is_async, diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index 739b4087f5b..298a8b8f0bd 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -876,7 +876,7 @@ class LiteLLM_Proxy_MCP_Handler: if litellm_logging_obj: try: litellm_logging_obj.post_call(original_response=result) - await litellm_logging_obj.async_post_mcp_tool_call_hook( + result = await litellm_logging_obj.async_post_mcp_tool_call_hook( kwargs=litellm_logging_obj.model_call_details, response_obj=result, start_time=start_time, diff --git a/litellm/router.py b/litellm/router.py index 06a65b85cc3..7267f6eb3ba 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -31,6 +31,7 @@ from collections.abc import ( MutableMapping, Sequence, ) +from datetime import datetime, timezone from functools import lru_cache, partial from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypeVar, Union, cast @@ -138,12 +139,14 @@ from litellm.router_strategy.tag_based_routing import ( get_deployments_for_tag, is_valid_deployment_tag, ) +from litellm.router_utils.access_windows import access_windows_config_error, filter_reserved_deployments from litellm.router_utils.add_retry_fallback_headers import ( _HiddenParamsHost, add_fallback_headers_to_response, add_retry_headers_to_response, apply_quality_router_decision_headers, apply_remaining_usage_headers, + apply_response_model_id, complexity_router_decision_headers, ensure_response_additional_headers, get_hidden_params_dict, @@ -174,6 +177,8 @@ from litellm.router_utils.common_utils import ( _is_proxy_admin_request, filter_team_based_models, filter_web_search_deployments, + format_fallback_outcome_message, + format_no_fallback_group_message, get_request_team_id, provider_for_generic_call, resolve_model_group_alias, @@ -201,6 +206,7 @@ from litellm.router_utils.fallback_event_handlers import ( fallback_lookup_groups, fallbacks_disabled_for_request, get_fallback_model_group_for_lookup_groups, + get_pre_routing_selection, has_unattempted_fallback_target, mid_stream_fallback_hop_kwargs, per_request_fallback_controls, @@ -243,7 +249,11 @@ from litellm.router_utils.router_callbacks.track_deployment_metrics import ( increment_deployment_failures_for_current_minute, increment_deployment_successes_for_current_minute, ) -from litellm.router_utils.routing_groups import parse_routing_groups, validate_routing_strategy +from litellm.router_utils.routing_groups import ( + apply_routing_group_priority, + parse_routing_groups, + validate_routing_strategy, +) from litellm.scheduler import FlowItem, Scheduler from litellm.types.llms.openai import ( AllMessageValues, @@ -837,7 +847,9 @@ class Router: cooldown_time (float): Time to cooldown a deployment after failure in seconds. Defaults to 1. routing_strategy (Literal["simple-shuffle", "least-busy", "usage-based-routing", "latency-based-routing", "cost-based-routing"]): Routing strategy used for the implicit "default" group (any model not claimed by an entry in `routing_groups`). Defaults to "simple-shuffle". routing_strategy_args (dict): Additional args for the default group's routing strategy (e.g. latency window). Defaults to {}. - routing_groups (Optional[List[RoutingGroup]]): Named subsets of `model_name`s that use a per-group routing strategy and args. Each model belongs to at most one explicit group; everything else lands in the implicit "default" group driven by `routing_strategy` / `routing_strategy_args`. Defaults to None. + routing_groups (Optional[List[RoutingGroup]]): Named subsets of `model_name`s with a group routing strategy. + Priority groups apply only to group calls and may overlap. Other groups supply their members' default + strategy, with at most one such group per model. Unclaimed models use the top-level strategy. alerting_config (AlertingConfig): Slack alerting configuration. Defaults to None. provider_budget_config (ProviderBudgetConfig): Provider budget configuration. Use this to set llm_provider budget limits. example $100/day to OpenAI, $100/day to Azure, etc. Defaults to None. deployment_affinity_ttl_seconds (int): TTL for user-key -> deployment affinity mapping. Defaults to 3600. @@ -1445,10 +1457,10 @@ class Router: ) -> None: """ Validates and indexes `routing_groups`. Each `model_name` may belong to - at most one explicit group. Constructs per-group strategy selectors so + at most one non-priority group. Constructs per-group strategy selectors so groups with different `routing_strategy_args` track independent state. - Models not claimed by any explicit group are served by the implicit + Models not claimed by a non-priority group are served by the implicit `"default"` group, whose selectors are the `self._logger` attributes set up in `routing_strategy_init`. """ @@ -1462,6 +1474,8 @@ class Router: alias_names: Final = frozenset(self.model_group_alias or ()) for group in groups: if group.group_name in known_model_names or group.group_name in alias_names: + if group.routing_strategy == "priority": + raise ValueError("Priority routing group names must not shadow a model or alias") verbose_router_logger.warning( "routing_groups: group_name '%s' is shadowed by an existing model_name or model_group_alias; " "the group's strategy still applies to its members, but the name is not callable until renamed.", @@ -1472,7 +1486,7 @@ class Router: ( group, self._build_strategy_selector( - strategy=group.routing_strategy, + strategy="simple-shuffle" if group.routing_strategy == "priority" else group.routing_strategy, routing_strategy_args=group.routing_strategy_args or {}, register_callbacks=False, ), @@ -1497,7 +1511,10 @@ class Router: self._routing_groups: dict[str, RoutingGroup] = {group.group_name: group for group, _ in built} self._model_to_group: dict[str, str] = { - model_name: group.group_name for group, _ in built for model_name in group.models + model_name: group.group_name + for group, _ in built + if group.routing_strategy != "priority" + for model_name in group.models } self._group_selectors: dict[str, dict[str, RouterStrategySelector]] = { group.group_name: ( @@ -1547,11 +1564,16 @@ class Router: if routing_group is None: return None return [ # mutable-ok: matches _get_all_deployments' list contract expected by downstream filters - deployment + apply_routing_group_priority(routing_group, member, deployment) for member in routing_group.models for deployment in self._get_all_deployments(model_name=member, team_id=team_id) ] + def _is_priority_routing_group(self, model: str) -> bool: + resolved: Final = self._get_model_from_alias(model=model) or model + group: Final = self.get_routing_group(resolved) + return group is not None and group.routing_strategy == "priority" + def is_recognized_model(self, model: str) -> bool: """ Whether `model` names something this router serves directly: a @@ -1715,7 +1737,12 @@ class Router: self._bind_override_selector_to_request(override, override_selector, request_kwargs) return override, override_selector - group_name: Final = model if self.get_routing_group(model) is not None else self._model_to_group.get(model) + resolved_model: Final = self._get_model_from_alias(model=model) or model + group_name: Final = ( + resolved_model + if self.get_routing_group(resolved_model) is not None + else self._model_to_group.get(resolved_model) + ) if group_name is None: strategy = self._normalize_strategy(self.routing_strategy) attr: Final = self._DEFAULT_SELECTOR_ATTR_BY_STRATEGY.get(strategy or "") @@ -1724,6 +1751,8 @@ class Router: return strategy, selector group: Final = self._routing_groups[group_name] + if group.routing_strategy == "priority": + return "simple-shuffle", None strategy = self._normalize_strategy(group.routing_strategy) selector = self._group_selectors.get(group_name, {}).get(strategy or "") verbose_router_logger.debug("routing_group=%s model=%s strategy=%s", group_name, model, strategy) @@ -2240,6 +2269,7 @@ class Router: enable_responses_api_affinity=False, enable_session_id_affinity=False, model_group_affinity_config=self.model_group_affinity_config, + is_priority_group=self._is_priority_routing_group, ) self.optional_callbacks.append(affinity_callback) litellm.logging_callback_manager.add_litellm_callback(affinity_callback) @@ -2285,6 +2315,7 @@ class Router: enable_responses_api_affinity=enable_responses_api_affinity, enable_session_id_affinity=enable_session_id_affinity, model_group_affinity_config=self.model_group_affinity_config, + is_priority_group=self._is_priority_routing_group, ) self.optional_callbacks.append(affinity_callback) litellm.logging_callback_manager.add_litellm_callback(affinity_callback) @@ -2309,7 +2340,9 @@ class Router: ): continue if pre_call_check == "prompt_caching": - _callback = PromptCachingDeploymentCheck(cache=self.cache) + _callback = PromptCachingDeploymentCheck( + cache=self.cache, is_priority_group=self._is_priority_routing_group + ) elif pre_call_check == "router_budget_limiting": if self._get_router_deployment_budget_limiter() is not None: continue @@ -7063,7 +7096,7 @@ class Router: self, exception: Exception, original_model_group: str, - all_deployments: list[DeploymentTypedDict], + all_deployments: Sequence[DeploymentTypedDict], args: tuple, kwargs: dict, input_kwargs: dict, @@ -7160,6 +7193,9 @@ class Router: # behind the router name, and fallbacks are configured per tier, not per router. lookup_groups: Final[tuple[str, ...]] = fallback_lookup_groups(kwargs, model_group) fallback_failure_exception_str = "" + no_fallback_group_explained = False + hop_depth: Final = kwargs.get("fallback_depth") + nested_fallback_hop: Final = isinstance(hop_depth, int) and hop_depth > 0 if disable_fallbacks is True or original_model_group is None: raise e @@ -7186,7 +7222,8 @@ class Router: _request_team_id: Final[str | None] = (kwargs.get("metadata", {}) or {}).get("user_api_key_team_id") # Use wildcard-aware lookup so order-based fallback also works for model # groups resolved via pattern routing (e.g. `openai/*` -> `openai/gpt-4.1-mini`). - all_deployments: Final = self.get_model_list(model_name=original_model_group, team_id=_request_team_id) or [] + order_model_group: Final = get_pre_routing_selection(kwargs) or original_model_group + all_deployments: Final = self.get_model_list(model_name=order_model_group, team_id=_request_team_id) or () _order_set: Final[set] = { litellm.utils._get_deployment_order(d) for d in all_deployments @@ -7199,7 +7236,7 @@ class Router: skip_up_to: Final = current_target if current_target is not None else order_values[0] # Build order-based fallback entries (skip already-tried levels) order_fallback_entries: Final[list] = [ - {"model": original_model_group, "_target_order": o} for o in order_values if o > skip_up_to + {"model": order_model_group, "_target_order": o} for o in order_values if o > skip_up_to ] # Get external fallbacks — handle both standard and non-standard formats external_fallback_group: list | None = None @@ -7351,8 +7388,13 @@ class Router: " -> ".join(lookup_groups), masked_fallbacks, ) - if hasattr(original_exception, "message") and litellm.expose_router_debug_in_errors: - original_exception.message += f"No fallback model group found for lookup_groups={' -> '.join(lookup_groups)}. Fallbacks={masked_fallbacks}" + if ( + hasattr(original_exception, "message") + and litellm.expose_router_debug_in_errors + and not nested_fallback_hop + ): + original_exception.message += format_no_fallback_group_message(lookup_groups, fallbacks) + no_fallback_group_explained = True raise original_exception input_kwargs.update( @@ -7383,11 +7425,16 @@ class Router: cooldown_info, ) - if hasattr(original_exception, "message") and litellm.expose_router_debug_in_errors: - # add the available fallbacks to the exception - original_exception.message += f". Received Model Group={model_group}\nAvailable Model Group Fallbacks={mask_sensitive_structure(fallback_model_group)}" - if len(fallback_failure_exception_str) > 0: - original_exception.message += f"\nError doing the fallback: {fallback_failure_exception_str}" + attempted_fallback_group: Final = input_kwargs.get("fallback_model_group") + if ( + hasattr(original_exception, "message") + and litellm.expose_router_debug_in_errors + and not no_fallback_group_explained + and not nested_fallback_hop + ): + original_exception.message += format_fallback_outcome_message( + model_group, attempted_fallback_group, fallback_failure_exception_str + ) raise original_exception @@ -8774,6 +8821,9 @@ class Router: ) if ptu_error is not None and is_ptu_cost_attribution_enabled(): raise ValueError(ptu_error) + access_windows_error: Final = access_windows_config_error(_model_info, model_name=_model_name) + if access_windows_error is not None: + raise ValueError(access_windows_error) zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None litellm_params: Final[LiteLLM_Params] = LiteLLM_Params( **( # pyright: ignore[reportArgumentType] # untyped merged dict; already true for every field here @@ -11172,6 +11222,9 @@ class Router: return response additional_headers: Final = ensure_response_additional_headers(response) + apply_response_model_id( + response, find_deployment_metadata(request_kwargs) if request_kwargs is not None else None + ) additional_headers["x-litellm-model-group"] = model_group apply_quality_router_decision_headers(additional_headers, request_kwargs) additional_headers.update(complexity_router_decision_headers(request_kwargs)) @@ -11662,7 +11715,14 @@ class Router: else: continue - returned_models.extend(self._get_all_deployments(model_name=_router_model_name, model_alias=model_alias)) + if (alias_group := self.get_routing_group(_router_model_name)) is not None: + returned_models.extend( + {**row, "model_name": model_alias} for row in self._materialize_routing_group_rows((alias_group,)) + ) + else: + returned_models.extend( + self._get_all_deployments(model_name=_router_model_name, model_alias=model_alias) + ) return returned_models @@ -11693,7 +11753,7 @@ class Router: def _materialize_routing_group_rows(self, groups: tuple[RoutingGroup, ...]) -> tuple[DeploymentTypedDict, ...]: return tuple( - self._as_routing_group_row(deployment) + self._as_routing_group_row(apply_routing_group_priority(group, member, deployment)) for group in groups for member in group.models for deployment in self._get_all_deployments(model_name=member, model_alias=group.group_name) @@ -12000,7 +12060,10 @@ class Router: ): _settings_to_return[var] = self.lowestlatency_logger.routing_args.json() - _settings_to_return["routing_groups"] = [group.model_dump() for group in self._routing_groups.values()] + _settings_to_return["routing_groups"] = [ + group.model_dump(exclude=frozenset(("model_priorities",)) if group.model_priorities is None else None) + for group in self._routing_groups.values() + ] return _settings_to_return def update_settings(self, **kwargs): @@ -12535,12 +12598,30 @@ class Router: request_team_id: Final = get_request_team_id(request_kwargs) # check if aliases set on litellm model alias map if specific_deployment is True: - return model, self._get_deployment_by_litellm_model(model=model) + return model, self._drop_strategy_markers( + model, + self._filter_reserved_deployments( + model=model, + healthy_deployments=self._get_deployment_by_litellm_model(model=model), + request_team_id=request_team_id, + ), + ) elif model not in self.model_names and self.has_model_id(model): deployment: Final = self.get_deployment(model_id=model) if deployment is not None: deployment_model: Final = deployment.litellm_params.model - return deployment_model, deployment.model_dump(exclude_none=True) + return deployment_model, cast( # cast-ok: contract requires a plain dict for a single deployment + dict, + self._filter_reserved_deployments( + model=deployment_model, + healthy_deployments=( + cast( # cast-ok: model_dump of a router deployment + DeploymentTypedDict, deployment.model_dump(exclude_none=True) + ), + ), + request_team_id=request_team_id, + )[0], + ) raise ValueError( f"LiteLLM Router: Trying to call specific deployment, but Model ID :{model} does not exist in Model ID map" ) @@ -12558,8 +12639,26 @@ class Router: ) if early is not None: if not isinstance(early[1], list): - return early - return early[0], self._drop_strategy_markers(early[0], early[1]) + return early[0], cast( # cast-ok: contract requires a plain dict for a single deployment + dict, + self._filter_reserved_deployments( + model=early[0], + healthy_deployments=( + cast( # cast-ok: early resolve returns a router deployment + DeploymentTypedDict, early[1] + ), + ), + request_team_id=request_team_id, + )[0], + ) + return early[0], self._drop_strategy_markers( + early[0], + self._filter_reserved_deployments( + model=early[0], + healthy_deployments=early[1], + request_team_id=request_team_id, + ), + ) ## get healthy deployments ### get all deployments @@ -12569,10 +12668,14 @@ class Router: else self._get_all_deployments(model_name=model, team_id=request_team_id) ) _pre_model_access_group_filter_len: Final = len(healthy_deployments) - healthy_deployments = self._filter_deployments_by_model_access_groups( + healthy_deployments = self._filter_reserved_deployments( model=model, - healthy_deployments=healthy_deployments, - request_kwargs=request_kwargs, + healthy_deployments=self._filter_deployments_by_model_access_groups( + model=model, + healthy_deployments=healthy_deployments, + request_kwargs=request_kwargs, + request_team_id=request_team_id, + ), request_team_id=request_team_id, ) _access_group_filter_emptied_candidates = ( @@ -12585,10 +12688,14 @@ class Router: # _get_deployment_by_litellm_model does not re-apply that filter. if _pre_model_access_group_filter_len == 0: _litellm_model_deployments: Final = self._get_deployment_by_litellm_model(model=model) - healthy_deployments = self._filter_deployments_by_model_access_groups( + healthy_deployments = self._filter_reserved_deployments( model=model, - healthy_deployments=_litellm_model_deployments, - request_kwargs=request_kwargs, + healthy_deployments=self._filter_deployments_by_model_access_groups( + model=model, + healthy_deployments=_litellm_model_deployments, + request_kwargs=request_kwargs, + request_team_id=request_team_id, + ), request_team_id=request_team_id, ) # If the litellm-model lookup produced candidates that access-group @@ -12616,10 +12723,14 @@ class Router: # Re-assign model to the fallback and try to get deployments again model = fallback_model healthy_deployments = self._get_all_deployments(model_name=model, team_id=request_team_id) - healthy_deployments = self._filter_deployments_by_model_access_groups( + healthy_deployments = self._filter_reserved_deployments( model=model, - healthy_deployments=healthy_deployments, - request_kwargs=request_kwargs, + healthy_deployments=self._filter_deployments_by_model_access_groups( + model=model, + healthy_deployments=healthy_deployments, + request_kwargs=request_kwargs, + request_team_id=request_team_id, + ), request_team_id=request_team_id, ) @@ -12653,6 +12764,24 @@ class Router: ) return selectable + def _filter_reserved_deployments( + self, + model: str, + healthy_deployments: Sequence[DeploymentTypedDict], + request_team_id: str | None, + ) -> tuple[DeploymentTypedDict, ...]: + result: Final = filter_reserved_deployments( + self._drop_strategy_markers(model, healthy_deployments), request_team_id, now=datetime.now(timezone.utc) + ) + if result.blocking_window is not None and len(result.deployments) == 0: + raise litellm.BadRequestError( + message=f"Deployment {model} is reserved for another team until " + f"{result.blocking_window.end:%H:%M} {result.blocking_window.timezone}", + model=model, + llm_provider="", + ) + return result.deployments + def _filter_deployments_by_model_access_groups( self, model: str, @@ -14115,6 +14244,12 @@ class Router: request_kwargs=request_kwargs, ) + if self._is_priority_routing_group(model): + pass_through_deployments = litellm.utils.get_order_filtered_deployments( + pass_through_deployments, + target_order=request_kwargs.pop("_target_order", None) if request_kwargs is not None else None, + ) + if len(pass_through_deployments) == 0: model_ids = self.get_model_ids(model_name=model) _cooldown_time = self.cooldown_cache.get_min_cooldown( diff --git a/litellm/router_utils/access_windows.py b/litellm/router_utils/access_windows.py new file mode 100644 index 00000000000..5a44584b33d --- /dev/null +++ b/litellm/router_utils/access_windows.py @@ -0,0 +1,75 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Final, Generic, TypeVar +from zoneinfo import ZoneInfo + +from pydantic import TypeAdapter, ValidationError + +from litellm.types.router import ModelAccessWindow + +_WINDOWS_ADAPTER: Final = TypeAdapter(tuple[ModelAccessWindow, ...]) + +_DeploymentT = TypeVar("_DeploymentT", bound=Mapping[str, object]) + + +def parse_access_windows(model_info: Mapping[str, object]) -> tuple[ModelAccessWindow, ...]: + raw: Final = model_info.get("access_windows") + if raw is None: + return () + return _WINDOWS_ADAPTER.validate_python(raw) + + +def access_windows_config_error(model_info: Mapping[str, object], *, model_name: str) -> str | None: + if model_info.get("access_windows") is None: + return None + try: + parse_access_windows(model_info) + except ValidationError as exc: + first: Final = exc.errors()[0] + loc: Final = ".".join(str(part) for part in first["loc"]) + return f"model '{model_name}': invalid model_info.access_windows: {loc}: {first['msg']}" + return None + + +def is_window_active(window: ModelAccessWindow, now: datetime) -> bool: + aware: Final = now if now.tzinfo is not None else now.replace(tzinfo=timezone.utc) + local: Final = aware.astimezone(ZoneInfo(window.timezone)).time() + if window.start < window.end: + return window.start <= local < window.end + return local >= window.start or local < window.end + + +@dataclass(frozen=True, slots=True) +class ReservationFilterResult(Generic[_DeploymentT]): + deployments: tuple[_DeploymentT, ...] + blocking_window: ModelAccessWindow | None + + +def _reservation_blocking_window( + deployment: Mapping[str, object], request_team_id: str | None, now: datetime +) -> ModelAccessWindow | None: + model_info: Final = deployment.get("model_info") + if not isinstance(model_info, Mapping): + return None + active: Final = tuple(window for window in parse_access_windows(model_info) if is_window_active(window, now)) + if not active: + return None + if request_team_id is not None and any(request_team_id in window.team_ids for window in active): + return None + return active[0] + + +def filter_reserved_deployments( + healthy_deployments: Sequence[_DeploymentT], + request_team_id: str | None, + now: datetime, +) -> ReservationFilterResult[_DeploymentT]: + checks: Final = tuple( + (deployment, _reservation_blocking_window(deployment, request_team_id, now)) + for deployment in healthy_deployments + ) + return ReservationFilterResult( + deployments=tuple(deployment for deployment, blocking in checks if blocking is None), + blocking_window=next((blocking for _, blocking in checks if blocking is not None), None), + ) diff --git a/litellm/router_utils/add_retry_fallback_headers.py b/litellm/router_utils/add_retry_fallback_headers.py index cbca5880b52..6e07693b7ea 100644 --- a/litellm/router_utils/add_retry_fallback_headers.py +++ b/litellm/router_utils/add_retry_fallback_headers.py @@ -131,6 +131,19 @@ def ensure_response_additional_headers(response: object) -> dict[str, object]: return additional_headers +def apply_response_model_id(response: object, request_metadata: Mapping[str, object] | None) -> None: + if request_metadata is None: + return + model_id: Final = _routing_header_mapping(request_metadata.get("model_info")).get("id") + if not isinstance(model_id, str) or not model_id: + return + hidden_params: Final = get_hidden_params_dict(response, create=isinstance(response, dict)) + if hidden_params.get("model_id"): + return + hidden_params["model_id"] = model_id + _write_hidden_params(response, hidden_params) + + def apply_quality_router_decision_headers( additional_headers: dict[str, object], request_kwargs: object, diff --git a/litellm/router_utils/common_utils.py b/litellm/router_utils/common_utils.py index 49cca8ee99e..7d111a80264 100644 --- a/litellm/router_utils/common_utils.py +++ b/litellm/router_utils/common_utils.py @@ -1,6 +1,6 @@ import hashlib import json -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Final @@ -12,6 +12,7 @@ from litellm._logging import verbose_logger, verbose_router_logger from litellm.constants import ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_structure from litellm.types.router import CredentialLiteLLMParams from litellm.types.utils import LlmProviders @@ -76,6 +77,36 @@ def truncate_fallback_error_detail(detail: str) -> str: return f"{detail[:ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS]}... [truncated {dropped} characters]" +def format_no_fallback_group_message(lookup_groups: Sequence[str], fallbacks: Sequence[Mapping[str, object]]) -> str: + """User-facing explanation appended when a request fails and no fallback chain matches its model group.""" + requested: Final = " -> ".join(lookup_groups) + configured: Final = tuple(dict.fromkeys(key for entry in fallbacks for key in entry)) + configured_text: Final = ( + f" Fallbacks are configured for: {', '.join(configured)}." if configured else " No fallbacks are configured." + ) + return ( + f"\n\nLiteLLM: model group '{requested}' failed with the error above and no fallback model group was found " + f"for it, so the request was not retried on another model.{configured_text}" + " Add a fallbacks entry for that model group (Router fallbacks or proxy router_settings.fallbacks)" + " to retry on another model." + ) + + +def format_fallback_outcome_message( + model_group: str | None, + fallback_model_group: Sequence[object] | None, + fallback_failure_detail: str, +) -> str: + """User-facing explanation appended when the fallback orchestrator gives up and re-raises the primary error.""" + lead: Final = f"\n\nLiteLLM: model group '{model_group}' failed with the error above." + if not fallback_model_group: + return f"{lead} No fallback was attempted." + targets: Final = ", ".join(str(mask_sensitive_structure(target)) for target in fallback_model_group) + if not fallback_failure_detail: + return f"{lead} Fallback model group(s) configured: {targets}." + return f"{lead} Fallback to {targets} also failed: {fallback_failure_detail}" + + def get_litellm_params_sensitive_credential_hash(litellm_params: dict) -> str: """ Hash of the credential params, used for mapping the file id to the right model diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 61e0d82e66b..4745e4094e6 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -498,7 +498,12 @@ async def _is_fallback_target_authorized( ) -> bool: access_check: Final = litellm_router.fallback_access_check target: Final = _get_fallback_target_model_group(fallback_entry) - if access_check is None or target is None or target == original_model_group: + if ( + access_check is None + or target is None + or target == original_model_group + or target == get_pre_routing_selection(kwargs) + ): return True if await access_check(model=target, request_kwargs=kwargs, llm_router=litellm_router): return True diff --git a/litellm/router_utils/pre_call_checks/deployment_affinity_check.py b/litellm/router_utils/pre_call_checks/deployment_affinity_check.py index 3b88ac2eb00..edba4c27647 100644 --- a/litellm/router_utils/pre_call_checks/deployment_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/deployment_affinity_check.py @@ -13,7 +13,7 @@ where routing to a consistent deployment is still beneficial. """ import hashlib -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any, Final, cast from typing_extensions import ReadOnly, TypedDict @@ -79,6 +79,7 @@ class DeploymentAffinityCheck(CustomLogger): enable_responses_api_affinity: bool, enable_session_id_affinity: bool = False, model_group_affinity_config: dict[str, list[str]] | None = None, + is_priority_group: Callable[[str], bool] | None = None, ): super().__init__() self.cache = cache @@ -87,6 +88,7 @@ class DeploymentAffinityCheck(CustomLogger): self.enable_responses_api_affinity = enable_responses_api_affinity self.enable_session_id_affinity = enable_session_id_affinity self.model_group_affinity_config: dict[str, list[str]] = model_group_affinity_config or {} + self.is_priority_group = is_priority_group def _get_effective_flags(self, model_group: str) -> tuple[bool, bool, bool]: """ @@ -384,6 +386,9 @@ class DeploymentAffinityCheck(CustomLogger): ) return [deployment] + if self.is_priority_group is not None and self.is_priority_group(model): + return typed_healthy_deployments + stable_model_map_key: Final = self._get_stable_model_map_key_from_deployments( healthy_deployments=typed_healthy_deployments ) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index 0589e290b47..eabd79f1847 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -4,6 +4,7 @@ Check if prompt caching is valid for a given deployment Route to previously cached model id, if valid """ +from collections.abc import Callable from typing import Final, cast from litellm import verbose_logger @@ -48,8 +49,10 @@ def _get_min_token_count_for_deployments(healthy_deployments: list[dict]) -> int class PromptCachingDeploymentCheck(CustomLogger): - def __init__(self, cache: DualCache): + def __init__(self, cache: DualCache, is_priority_group: Callable[[str], bool] | None = None): + super().__init__() self.cache = cache + self.is_priority_group = is_priority_group async def async_filter_deployments( self, @@ -59,6 +62,8 @@ class PromptCachingDeploymentCheck(CustomLogger): request_kwargs: dict | None = None, parent_otel_span: Span | None = None, ) -> list[dict]: + if self.is_priority_group is not None and self.is_priority_group(model): + return healthy_deployments if request_kwargs is not None and request_kwargs.get("_target_order") is not None: return healthy_deployments diff --git a/litellm/router_utils/routing_groups.py b/litellm/router_utils/routing_groups.py index 772c8bd805c..4438e53ca7d 100644 --- a/litellm/router_utils/routing_groups.py +++ b/litellm/router_utils/routing_groups.py @@ -2,7 +2,20 @@ from collections.abc import Sequence from typing import Final from litellm._logging import verbose_router_logger -from litellm.types.router import RoutingGroup, RoutingStrategy +from litellm.types.router import DeploymentTypedDict, RoutingGroup, RoutingStrategy + + +def apply_routing_group_priority( + group: RoutingGroup, member: str, deployment: DeploymentTypedDict +) -> DeploymentTypedDict: + if group.routing_strategy != "priority" or group.model_priorities is None: + return deployment + prioritized: Final[DeploymentTypedDict] = { + **deployment, + "litellm_params": {**deployment["litellm_params"], "order": group.model_priorities[member]}, + } + return prioritized + VALID_ROUTING_STRATEGIES: Final = ("simple-shuffle", "lar1", *(s.value for s in RoutingStrategy)) @@ -43,10 +56,18 @@ def parse_routing_groups( raise ValueError(f"routing_groups: group names must be unique, duplicate group_name '{min(duplicate_names)}'.") for group in groups: - validate_routing_strategy(group.routing_strategy) + if group.routing_strategy != "priority": + validate_routing_strategy(group.routing_strategy) owners_by_model: Final = tuple( - (model_name, tuple(group.group_name for group in groups if model_name in group.models)) + ( + model_name, + tuple( + group.group_name + for group in groups + if group.routing_strategy != "priority" and model_name in group.models + ), + ) for model_name in dict.fromkeys(model_name for group in groups for model_name in group.models) ) conflicts: Final = tuple( diff --git a/litellm/rust_bridge/_native.pyi b/litellm/rust_bridge/_native.pyi index 61e597bf674..f794dc4a99e 100644 --- a/litellm/rust_bridge/_native.pyi +++ b/litellm/rust_bridge/_native.pyi @@ -105,6 +105,7 @@ class _ResponseCacheRuntime: *, callback_kwargs: Mapping[str, object] | Sequence[object] | None = None, ) -> object: ... + def lookup_semantic(self, request: object) -> tuple[object, float | None]: ... def store( self, request: object, @@ -124,6 +125,7 @@ class _ResponseCacheRuntime: *, callback_kwargs: Mapping[str, object] | None = None, ) -> Future[object]: ... + def async_lookup_semantic(self, request: object) -> Future[tuple[object, float | None]]: ... def async_store( self, request: object, diff --git a/litellm/rust_bridge/catalog.py b/litellm/rust_bridge/catalog.py index d7479631e04..d6cbe17d790 100644 --- a/litellm/rust_bridge/catalog.py +++ b/litellm/rust_bridge/catalog.py @@ -93,9 +93,9 @@ Rules: TypeAlias = tuple[Rule, ...] RULES: Final[Rules] = ( RouteRule(Route.OCR, Rollout.RUST_REQUIRED, providers=frozenset({"aws_textract"})), RouteRule(Route.OCR, Rollout.RUST_OPT_OUT), - RouteRule(Route.MESSAGES, Rollout.RUST_OPT_IN), - RouteRule(Route.TOKEN_COUNTER, Rollout.RUST_OPT_IN), - RouteRule(Route.TOKENIZER, Rollout.RUST_OPT_IN), + RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY), + RouteRule(Route.TOKEN_COUNTER, Rollout.PYTHON_ONLY), + RouteRule(Route.TOKENIZER, Rollout.PYTHON_ONLY), RouteRule(Route.TRANSCRIPTION, Rollout.RUST_REQUIRED, providers=frozenset({"bedrock"})), CacheRule(Rollout.PYTHON_ONLY, backends=frozenset({LiteLLMCacheType.LOCAL})), CacheRule(Rollout.PYTHON_ONLY, backends=frozenset({LiteLLMCacheType.REDIS})), diff --git a/litellm/rust_bridge/response_cache.py b/litellm/rust_bridge/response_cache.py index 82d27fce27b..a6fc121a3b5 100644 --- a/litellm/rust_bridge/response_cache.py +++ b/litellm/rust_bridge/response_cache.py @@ -46,9 +46,11 @@ class NativeResponseCacheRuntime(Protocol): def kind(self) -> str: ... def lookup(self, request: NativeCacheRequest) -> object: ... + def lookup_semantic(self, request: NativeCacheRequest) -> tuple[object, float | None]: ... def store(self, request: NativeCacheRequest, response: object) -> None: ... def lookup_batch(self, requests: Sequence[NativeCacheRequest]) -> object: ... def async_lookup(self, request: NativeCacheRequest) -> Awaitable[object]: ... + def async_lookup_semantic(self, request: NativeCacheRequest) -> Awaitable[tuple[object, float | None]]: ... def async_store(self, request: NativeCacheRequest, response: object) -> Awaitable[None]: ... def async_lookup_batch(self, requests: Sequence[NativeCacheRequest]) -> Awaitable[object]: ... def async_store_batch( @@ -108,6 +110,11 @@ class ResponseCacheRuntime: def lookup(self, request: NativeCacheRequest) -> object: return self.native.lookup(request) + def lookup_semantic(self, request: NativeCacheRequest) -> tuple[object, float | None]: + """The cached response and the similarity a semantic backend reports, if any.""" + response, similarity = self.native.lookup_semantic(request) + return response, similarity + def store(self, request: NativeCacheRequest, response: object) -> None: self.native.store(request, response) @@ -117,6 +124,10 @@ class ResponseCacheRuntime: async def async_lookup(self, request: NativeCacheRequest) -> object: return await self.native.async_lookup(request) + async def async_lookup_semantic(self, request: NativeCacheRequest) -> tuple[object, float | None]: + response, similarity = await self.native.async_lookup_semantic(request) + return response, similarity + async def async_store(self, request: NativeCacheRequest, response: object) -> None: await self.native.async_store(request, response) diff --git a/litellm/secret_managers/aws_secret_manager_v2.py b/litellm/secret_managers/aws_secret_manager_v2.py index 0fb59105b5f..3e9f4e259d5 100644 --- a/litellm/secret_managers/aws_secret_manager_v2.py +++ b/litellm/secret_managers/aws_secret_manager_v2.py @@ -16,6 +16,8 @@ Requires: import json import os +from collections.abc import Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Final import httpx @@ -294,32 +296,13 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager): raise ValueError("Tags must be a dict or list of {Key, Value} pairs") data["Tags"] = tags_list - endpoint_url, headers, body = self._prepare_request( - action="CreateSecret", + create_response: Final = await self._async_create_or_restore_secret( secret_name=secret_name, - secret_value=secret_value, - optional_params=optional_params, request_data=data, + optional_params=optional_params, + timeout=timeout, ) - async_client: Final = get_async_httpx_client( - llm_provider=httpxSpecialProvider.SecretManager, - params={"timeout": timeout}, - ) - - try: - response: Final = await async_client.post( - url=endpoint_url, - headers=headers, - data=body.decode("utf-8"), - ) - response.raise_for_status() - create_response: Final = response.json() - except httpx.HTTPStatusError as err: - raise ValueError(f"HTTP error occurred: {err.response.text}") - except httpx.TimeoutException: - raise ValueError("Timeout error occurred") - if self.replica_regions: try: await self.async_replicate_secret( @@ -343,6 +326,110 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager): return create_response + async def _async_create_or_restore_secret( + self, + secret_name: str, + request_data: Mapping[str, object], + optional_params: dict | None, + timeout: float | httpx.Timeout | None, + ) -> dict[str, object]: + try: + return await self._async_post_action( + action="CreateSecret", + secret_name=secret_name, + request_data=request_data, + optional_params=optional_params, + timeout=timeout, + ) + except ValueError: + if not await self._async_is_scheduled_for_deletion( + secret_name=secret_name, + optional_params=optional_params, + timeout=timeout, + ): + raise + + verbose_logger.info( + "Secret %s is scheduled for deletion, restoring and updating in place (RestoreSecret + UpdateSecret)", + secret_name, + ) + await self._async_post_action( + action="RestoreSecret", + secret_name=secret_name, + request_data=None, + optional_params=optional_params, + timeout=timeout, + ) + update_data: Final = MappingProxyType( + {("SecretId" if key == "Name" else key): value for key, value in request_data.items() if key != "Tags"} + ) + tags: Final = request_data.get("Tags") + try: + updated: Final = await self._async_post_action( + action="UpdateSecret", + secret_name=secret_name, + request_data=update_data, + optional_params=optional_params, + timeout=timeout, + ) + if tags is not None: + await self._async_post_action( + action="TagResource", + secret_name=secret_name, + request_data=MappingProxyType({"SecretId": secret_name, "Tags": tags}), + optional_params=optional_params, + timeout=timeout, + ) + except ValueError: + await self.async_delete_secret(secret_name=secret_name, optional_params=optional_params, timeout=timeout) + raise + return updated + + async def _async_is_scheduled_for_deletion( + self, + secret_name: str, + optional_params: dict | None, + timeout: float | httpx.Timeout | None, + ) -> bool: + try: + described: Final = await self._async_post_action( + action="DescribeSecret", + secret_name=secret_name, + request_data=None, + optional_params=optional_params, + timeout=timeout, + ) + except ValueError: + return False + return described.get("DeletedDate") is not None + + async def _async_post_action( + self, + action: str, + secret_name: str, + request_data: Mapping[str, object] | None, + optional_params: dict | None, + timeout: float | httpx.Timeout | None, + ) -> dict[str, object]: + endpoint_url, headers, body = self._prepare_request( + action=action, + secret_name=secret_name, + optional_params=optional_params, + request_data=dict(request_data) if request_data is not None else None, + ) + async_client: Final = get_async_httpx_client( + llm_provider=httpxSpecialProvider.SecretManager, + params={"timeout": timeout}, + ) + try: + response: Final = await async_client.post(url=endpoint_url, headers=headers, data=body.decode("utf-8")) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as err: + raise ValueError(f"HTTP error occurred: {err.response.text}") + except httpx.TimeoutException: + raise ValueError("Timeout error occurred") + async def async_replicate_secret( self, secret_name: str, diff --git a/litellm/types/management_endpoints/router_settings_endpoints.py b/litellm/types/management_endpoints/router_settings_endpoints.py index cef180b202a..4d1ac58edb0 100644 --- a/litellm/types/management_endpoints/router_settings_endpoints.py +++ b/litellm/types/management_endpoints/router_settings_endpoints.py @@ -80,6 +80,7 @@ class RouterSettingsField(BaseModel): # Routing strategy descriptions ROUTING_STRATEGY_DESCRIPTIONS: Final[dict[str, str]] = { + "priority": "Routes group calls to the lowest-priority-number available model, with failover to higher numbers. Equal priorities share traffic. Direct member calls keep their existing policy.", "simple-shuffle": "Randomly picks a deployment from the list. Simple and fast.", "least-busy": "Routes to the deployment with the lowest number of ongoing requests.", "latency-based-routing": "Routes to the deployment with the lowest latency over a sliding window.", diff --git a/litellm/types/router.py b/litellm/types/router.py index 8ca27a9fb66..57bd4263894 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -6,7 +6,8 @@ import datetime import enum from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypeVar, get_type_hints +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Final, Generic, Literal, TypeVar, get_type_hints +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -15,6 +16,7 @@ from typing_extensions import Protocol, ReadOnly, Required, TypedDict, runtime_c from litellm._logging import verbose_logger from litellm._uuid import uuid from litellm.litellm_core_utils.core_helpers import normalize_drop_params +from litellm.litellm_core_utils.provider_affinity import validate_provider_affinity_header_name from litellm.types.router_weights import RouterWeights if TYPE_CHECKING: @@ -59,6 +61,25 @@ class RoutingGroup(BaseModel): routing_strategy: str routing_strategy_args: dict | None = None + model_priorities: dict[str, Annotated[int, Field(strict=True, ge=1, le=9007199254740991)]] | None = Field( + default=None, + description="For priority groups, every model's priority. Lower numbers are tried first; equal numbers share traffic.", + ) + + @model_validator(mode="after") + def _validate_model_priorities(self) -> "RoutingGroup": + if self.routing_strategy != "priority": + if self.model_priorities: + raise ValueError("model_priorities requires routing_strategy='priority'") + return self + if not self.models or len(self.models) != len(frozenset(self.models)): + raise ValueError("Priority routing groups require nonempty, distinct models") + if self.model_priorities is None or frozenset(self.model_priorities) != frozenset(self.models): + raise ValueError("model_priorities must contain exactly the group's models") + if self.routing_strategy_args: + raise ValueError("Priority routing groups use model_priorities, not routing_strategy_args") + return self + model_config = ConfigDict(protected_namespaces=()) @@ -163,6 +184,44 @@ def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None: return value.astimezone(datetime.timezone.utc) +class ModelAccessWindow(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + start: datetime.time + end: datetime.time + timezone: str + team_ids: tuple[str, ...] = Field(min_length=1) + + @field_validator("start", "end") + @classmethod + def _naive_wall_clock(cls, value: datetime.time) -> datetime.time: + if value.tzinfo is not None: + raise ValueError("start and end must be local wall-clock times without a UTC offset") + return value + + @field_validator("timezone") + @classmethod + def _known_iana_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise ValueError(f"unknown IANA timezone '{value}'") from exc + return value + + @field_validator("team_ids") + @classmethod + def _non_empty_team_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any(not team_id for team_id in value): + raise ValueError("team_ids entries must be non-empty") + return value + + @model_validator(mode="after") + def _start_differs_from_end(self) -> "ModelAccessWindow": + if self.start == self.end: + raise ValueError("start and end must differ") + return self + + class ModelInfo(MirroredPricingParams): id: str | None # Allow id to be optional on input, but it will always be present as a str in the model instance db_model: bool = False # used for proxy - to separate models which are stored in the db vs. config. @@ -188,6 +247,8 @@ class ModelInfo(MirroredPricingParams): # admin-toggled pause flag; mirrors LiteLLM_ProxyModelTable.blocked blocked: bool | None = None + access_windows: tuple[ModelAccessWindow, ...] | None = None + # Bounds live on the model rather than litellm.constants: names there reach # litellm/__init__ through several modules' star re-exports, and a Final rebound that # way trips the basedpyright gate. @@ -336,6 +397,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): organization: str | None = None # for openai orgs configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None litellm_credential_name: str | None = None + provider_affinity_header: str | None = None ## LOGGING PARAMS ## litellm_trace_id: str | None = None @@ -407,6 +469,13 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): valkey_text_field: str | None = None valkey_embedding_field: str | None = None + @field_validator("provider_affinity_header") + @classmethod + def validate_provider_affinity_header(cls, value: str | None) -> str | None: + if value is None: + return None + return validate_provider_affinity_header_name(value) + @model_validator(mode="before") @classmethod def preprocess_input_data(cls, data: object) -> object: @@ -509,6 +578,7 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): stream_timeout: float | str | None max_retries: int | None organization: list | str | None # for openai orgs + provider_affinity_header: ReadOnly[str | None] configurable_clientside_auth_params: ( CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS # for allowing api base switching on finetuned models ) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index f10f102d8ec..f2c8f0e7044 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4073,6 +4073,7 @@ all_litellm_params = ( "litellm_credential_name", "allowed_openai_params", "litellm_session_id", + "provider_affinity_header", "use_litellm_proxy", "use_chat_completions_api", "rust", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8d68e325198..b48bdd6322c 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -53,13 +53,6 @@ "mode": "image_generation", "output_cost_per_image": 0.04 }, - "1024-x-1024/dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 1.9e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "1024-x-1024/max-steps/stability.stable-diffusion-xl-v1": { "litellm_provider": "bedrock", "max_input_tokens": 77, @@ -67,13 +60,6 @@ "mode": "image_generation", "output_cost_per_image": 0.08 }, - "256-x-256/dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 2.4414e-07, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "512-x-512/50-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, @@ -81,13 +67,6 @@ "mode": "image_generation", "output_cost_per_image": 0.018 }, - "512-x-512/dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 6.86e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "512-x-512/max-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, @@ -578,17 +557,6 @@ "supports_vision": true, "supports_tool_choice": true }, - "amazon.nova-sonic-v1:0": { - "deprecation_date": "2026-09-14", - "input_cost_per_audio_token": 3.4e-06, - "input_cost_per_token": 6e-08, - "litellm_provider": "bedrock", - "mode": "realtime", - "output_cost_per_audio_token": 1.36e-05, - "output_cost_per_token": 2.4e-07, - "supports_audio_input": true, - "supports_audio_output": true - }, "amazon.nova-2-sonic-v1:0": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 3.3e-07, @@ -972,23 +940,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", @@ -1004,23 +955,6 @@ "cache_read_input_token_cost": 1.5e-06, "cache_creation_input_token_cost": 1.875e-05 }, - "anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", @@ -2982,60 +2916,6 @@ "supports_vision": true, "supports_tool_choice": true }, - "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, - "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "apac.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, @@ -3063,23 +2943,6 @@ "input_cost_per_token_batches": 5.5e-07, "output_cost_per_token_batches": 2.75e-06 }, - "apac.anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "apac.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -3457,29 +3320,6 @@ "supports_max_reasoning_effort": true, "prompt_cache_min_tokens": 1024 }, - "azure_ai/claude-opus-4-1": { - "deprecation_date": "2026-08-05", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "azure_ai", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "azure_ai/claude-sonnet-4-5": { "deprecation_date": "2026-10-19", "cache_creation_input_token_cost": 3.75e-06, @@ -4428,7 +4268,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 6.875e-08 }, "azure/eu/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, @@ -4467,7 +4308,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.375e-08 }, "azure/eu/gpt-5.1": { "deprecation_date": "2027-05-15", @@ -4512,43 +4354,6 @@ "output_cost_per_token_priority": 2.2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.375e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.375e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1.1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.375e-07, @@ -4647,7 +4452,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 2.75e-09 }, "azure/eu/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, @@ -4685,21 +4491,6 @@ "supports_prompt_caching": true, "supports_vision": false }, - "azure/eu/o1-preview-2024-09-12": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_vision": false - }, "azure/eu/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, "deprecation_date": "2026-11-19", @@ -4855,43 +4646,6 @@ "output_cost_per_token_priority": 2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/global/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/global/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, @@ -4967,19 +4721,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "azure/gpt-3.5-turbo-0125": { - "deprecation_date": "2025-03-31", - "input_cost_per_token": 5e-07, - "litellm_provider": "azure", - "max_input_tokens": 16384, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, "azure/gpt-3.5-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, "litellm_provider": "azure_text", @@ -4999,32 +4740,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "azure/gpt-35-turbo-0125": { - "deprecation_date": "2025-05-31", - "input_cost_per_token": 5e-07, - "litellm_provider": "azure", - "max_input_tokens": 16384, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, - "azure/gpt-35-turbo-1106": { - "deprecation_date": "2025-03-31", - "input_cost_per_token": 1e-06, - "litellm_provider": "azure", - "max_input_tokens": 16384, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 2e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, "azure/gpt-35-turbo-16k": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", @@ -5792,40 +5507,6 @@ "supports_system_messages": true, "supports_tool_choice": true }, - "azure/gpt-realtime-2": { - "cache_read_input_audio_token_cost": 4e-07, - "cache_read_input_token_cost": 4e-07, - "deprecation_date": "2026-08-31", - "input_cost_per_audio_token": 3.2e-05, - "input_cost_per_image_token": 5e-06, - "input_cost_per_token": 4e-06, - "litellm_provider": "azure", - "max_input_tokens": 32000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 6.4e-05, - "output_cost_per_token": 2.4e-05, - "source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure", - "supported_endpoints": [ - "/v1/realtime" - ], - "supported_modalities": [ - "text", - "image", - "audio" - ], - "supported_output_modalities": [ - "text", - "audio" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "azure/gpt-realtime-2.1": { "cache_creation_input_audio_token_cost": 4e-07, "cache_read_input_audio_token_cost": 4e-07, @@ -6120,46 +5801,8 @@ "input_cost_per_token_batches": 6.25e-07, "output_cost_per_token_batches": 5e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_minimal_reasoning_effort": true - }, - "azure/gpt-5.1-chat-2025-11-13": { - "cache_read_input_token_cost": 1.25e-07, - "cache_read_input_token_cost_priority": 2.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "input_cost_per_token_priority": 2.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "output_cost_per_token_priority": 2e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_native_streaming": true, - "supports_parallel_function_calling": false, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": false, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 6.25e-08 }, "azure/gpt-5.1-codex-2025-11-13": { "cache_read_input_token_cost": 1.25e-07, @@ -6308,74 +5951,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5-chat": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5-chat-latest": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 6.25e-08 }, "azure/gpt-5-codex": { "cache_read_input_token_cost": 1.25e-07, @@ -6486,7 +6063,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "azure/gpt-5-nano": { "deprecation_date": "2027-02-09", @@ -6559,7 +6137,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 2.5e-09 }, "azure/gpt-5-pro": { "deprecation_date": "2027-04-07", @@ -6636,43 +6215,6 @@ "output_cost_per_token_priority": 2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.25e-07, @@ -6848,79 +6390,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5.2-chat": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "azure/gpt-5.2-chat-2025-12-11": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-05-13", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 8.75e-08 }, "azure/gpt-5.2-codex": { "cache_read_input_token_cost": 1.75e-07, @@ -6954,42 +6425,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "azure/gpt-5.3-chat": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "azure/gpt-5.3-codex": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_priority": 3.5e-07, @@ -7313,7 +6748,11 @@ "output_cost_per_token_flex": 7.5e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_above_272k_tokens_batches": 2.5e-07, + "cache_read_input_token_cost_batches": 1.3e-07, + "input_cost_per_token_above_272k_tokens_batches": 2.5e-06, + "output_cost_per_token_above_272k_tokens_batches": 1.125e-05 }, "azure/us/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.75e-07, @@ -7359,7 +6798,11 @@ "output_cost_per_token_batches": 8.25e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_above_272k_tokens_batches": 2.75e-07, + "cache_read_input_token_cost_batches": 1.43e-07, + "input_cost_per_token_above_272k_tokens_batches": 2.75e-06, + "output_cost_per_token_above_272k_tokens_batches": 1.2375e-05 }, "azure/eu/gpt-5.4-2026-03-05": { "cache_read_input_token_cost": 2.75e-07, @@ -7405,7 +6848,11 @@ "output_cost_per_token_batches": 8.25e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_above_272k_tokens_batches": 2.75e-07, + "cache_read_input_token_cost_batches": 1.43e-07, + "input_cost_per_token_above_272k_tokens_batches": 2.75e-06, + "output_cost_per_token_above_272k_tokens_batches": 1.2375e-05 }, "azure/gpt-5.4-pro": { "deprecation_date": "2027-09-07", @@ -7503,7 +6950,9 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "input_cost_per_token_above_272k_tokens_batches": 3e-05, + "output_cost_per_token_above_272k_tokens_batches": 0.000135 }, "azure/gpt-5.6": { "cache_creation_input_token_cost": 6.25e-06, @@ -8926,7 +8375,11 @@ "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", + "cache_read_input_token_cost_above_272k_tokens_batches": 5e-07, + "cache_read_input_token_cost_batches": 2.5e-07, + "input_cost_per_token_above_272k_tokens_batches": 5e-06, + "output_cost_per_token_above_272k_tokens_batches": 2.25e-05 }, "azure/us/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, @@ -9029,7 +8482,11 @@ "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", + "cache_read_input_token_cost_above_272k_tokens_batches": 5.5e-07, + "cache_read_input_token_cost_batches": 2.75e-07, + "input_cost_per_token_above_272k_tokens_batches": 5.5e-06, + "output_cost_per_token_above_272k_tokens_batches": 2.475e-05 }, "azure/eu/gpt-5.5-2026-04-23": { "cache_read_input_token_cost": 5.5e-07, @@ -9132,7 +8589,11 @@ "supports_none_reasoning_effort": true, "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" + "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", + "cache_read_input_token_cost_above_272k_tokens_batches": 5.5e-07, + "cache_read_input_token_cost_batches": 2.75e-07, + "input_cost_per_token_above_272k_tokens_batches": 5.5e-06, + "output_cost_per_token_above_272k_tokens_batches": 2.475e-05 }, "azure/gpt-5.5-pro": { "cache_read_input_token_cost": 3e-06, @@ -9319,7 +8780,8 @@ "output_cost_per_token_flex": 2.25e-06, "output_cost_per_token_priority": 9e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 3.75e-08 }, "azure/gpt-5.4-nano": { "deprecation_date": "2027-09-21", @@ -9416,7 +8878,8 @@ "output_cost_per_token_batches": 6.25e-07, "output_cost_per_token_flex": 6.25e-07, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1e-08 }, "azure/gpt-image-1": { "cache_read_input_token_cost": 1.25e-06, @@ -9819,39 +9282,6 @@ "supports_reasoning": true, "supports_vision": false }, - "azure/o1-preview": { - "cache_read_input_token_cost": 7.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_vision": false - }, - "azure/o1-preview-2024-09-12": { - "cache_read_input_token_cost": 7.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_vision": false - }, "azure/o3": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5e-07, @@ -10463,7 +9893,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 6.875e-08 }, "azure/us/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.75e-08, @@ -10502,7 +9933,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.375e-08 }, "azure/us/gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5.5e-09, @@ -10538,7 +9970,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 2.75e-09 }, "azure/us/gpt-5.1": { "deprecation_date": "2027-05-15", @@ -10583,43 +10016,6 @@ "output_cost_per_token_priority": 2.2e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/gpt-5.1-chat": { - "cache_read_input_token_cost": 1.375e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 1.375e-06, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1.1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/gpt-5.1-codex": { "deprecation_date": "2027-05-15", "cache_read_input_token_cost": 1.375e-07, @@ -10720,21 +10116,6 @@ "supports_prompt_caching": true, "supports_vision": false }, - "azure/us/o1-preview-2024-09-12": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "max_input_tokens": 128000, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_vision": false - }, "azure/us/o3-2025-04-16": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, @@ -11236,18 +10617,6 @@ "/v1/images/edits" ] }, - "azure_ai/MAI-Image-2e": { - "deprecation_date": "2026-08-15", - "input_cost_per_token": 5e-06, - "litellm_provider": "azure_ai", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "output_cost_per_image_token": 1.95e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supported_endpoints": [ - "/v1/images/generations" - ] - }, "azure_ai/MAI-Thinking-1": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, @@ -11272,34 +10641,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "azure_ai/Llama-3.2-11B-Vision-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 3.7e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 3.7e-07, - "source": "https://marketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-11b-vision-instruct-offer?tab=Overview", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "azure_ai/Llama-3.2-90B-Vision-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 2.04e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 2.04e-06, - "source": "https://marketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-90b-vision-instruct-offer?tab=Overview", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, "azure_ai/Llama-3.3-70B-Instruct": { "input_cost_per_token": 7.1e-07, "litellm_provider": "azure_ai", @@ -11348,18 +10689,6 @@ "output_cost_per_token": 3.7e-07, "supports_tool_choice": true }, - "azure_ai/Meta-Llama-3.1-405B-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 5.33e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 1.6e-05, - "source": "https://marketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-405b-instruct-offer?tab=PlansAndPrice", - "supports_tool_choice": true - }, "azure_ai/Meta-Llama-3.1-70B-Instruct": { "input_cost_per_token": 2.68e-06, "litellm_provider": "azure_ai", @@ -11371,18 +10700,6 @@ "source": "https://marketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-70b-instruct-offer?tab=PlansAndPrice", "supports_tool_choice": true }, - "azure_ai/Meta-Llama-3.1-8B-Instruct": { - "deprecation_date": "2026-06-13", - "input_cost_per_token": 3e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 2048, - "max_tokens": 2048, - "mode": "chat", - "output_cost_per_token": 6.1e-07, - "source": "https://marketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-8b-instruct-offer?tab=PlansAndPrice", - "supports_tool_choice": true - }, "azure_ai/Phi-3-medium-128k-instruct": { "input_cost_per_token": 1.7e-07, "litellm_provider": "azure_ai", @@ -11553,16 +10870,6 @@ "supports_tool_choice": true, "supports_reasoning": true }, - "azure_ai/mistral-document-ai-2505": { - "deprecation_date": "2026-07-20", - "litellm_provider": "azure_ai", - "ocr_cost_per_page": 0.003, - "mode": "ocr", - "supported_endpoints": [ - "/v1/ocr" - ], - "source": "https://devblogs.microsoft.com/foundry/whats-new-in-azure-ai-foundry-august-2025/#mistral-document-ai-(ocr)-%E2%80%94-serverless-in-foundry" - }, "azure_ai/mistral-document-ai-2512": { "litellm_provider": "azure_ai", "ocr_cost_per_page": 0.003, @@ -11663,17 +10970,6 @@ "mode": "rerank", "output_cost_per_token": 0.0 }, - "azure_ai/cohere-rerank-v3.5": { - "deprecation_date": "2026-05-14", - "input_cost_per_query": 0.002, - "input_cost_per_token": 0.0, - "litellm_provider": "azure_ai", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "rerank", - "output_cost_per_token": 0.0 - }, "azure_ai/cohere-rerank-v4.0-pro": { "input_cost_per_query": 0.0025, "input_cost_per_token": 0.0, @@ -11726,19 +11022,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "azure_ai/deepseek-r1": { - "deprecation_date": "2026-08-13", - "input_cost_per_token": 1.35e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 5.4e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_reasoning": true, - "supports_tool_choice": true - }, "azure_ai/deepseek-v3": { "input_cost_per_token": 1.14e-06, "litellm_provider": "azure_ai", @@ -11750,33 +11033,6 @@ "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", "supports_tool_choice": true }, - "azure_ai/deepseek-v3-0324": { - "deprecation_date": "2026-07-13", - "input_cost_per_token": 1.14e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 128000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4.56e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_tool_choice": true - }, - "azure_ai/deepseek-v3.1": { - "deprecation_date": "2026-07-13", - "input_cost_per_token": 1.23e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 4.94e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "azure_ai/deepseek-v4-pro": { "deprecation_date": "2028-02-20", "input_cost_per_token": 1.74e-06, @@ -11843,68 +11099,6 @@ ], "supports_embedding_image_input": true }, - "azure_ai/global/grok-3": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 3e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/global/grok-3-mini": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.27e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/grok-3": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 3e-06, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/grok-3-mini": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.27e-06, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true - }, "azure_ai/grok-4": { "input_cost_per_token": 3e-06, "litellm_provider": "azure_ai", @@ -11998,36 +11192,6 @@ "supports_vision": true, "supports_web_search": true }, - "azure_ai/grok-4-fast-non-reasoning": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2e-07, - "output_cost_per_token": 5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_web_search": true - }, - "azure_ai/grok-4-fast-reasoning": { - "deprecation_date": "2026-05-01", - "input_cost_per_token": 2e-07, - "output_cost_per_token": 5e-07, - "litellm_provider": "azure_ai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "azure_ai/grok-4-1-fast-non-reasoning": { "input_cost_per_token": 2e-07, "output_cost_per_token": 5e-07, @@ -12982,6 +12146,23 @@ "supports_tool_choice": true, "output_cost_per_token": 1.86e-06 }, + "bedrock/eu-west-2/nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 2.3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 256000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 1.01e-06, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, "bedrock/eu-west-2/qwen.qwen3-coder-next": { "input_cost_per_token": 7.8e-07, "litellm_provider": "bedrock", @@ -13534,40 +12715,6 @@ "mode": "chat", "output_cost_per_token": 1.5e-06 }, - "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, - "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 3e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-08, - "cache_creation_input_token_cost": 3.75e-07 - }, "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -13745,61 +12892,6 @@ "mode": "chat", "output_cost_per_token": 1.5e-06 }, - "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { - "cache_creation_input_token_cost": 4.5e-06, - "cache_read_input_token_cost": 3.6e-07, - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, - "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 3e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-08, - "cache_creation_input_token_cost": 3.75e-07 - }, "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -14237,34 +13329,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "cerebras/zai-glm-4.6": { - "deprecation_date": "2026-01-20", - "input_cost_per_token": 2.25e-06, - "litellm_provider": "cerebras", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-06, - "source": "https://www.cerebras.ai/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, - "cerebras/zai-glm-4.7": { - "deprecation_date": "2026-08-17", - "input_cost_per_token": 2.25e-06, - "litellm_provider": "cerebras", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-06, - "source": "https://www.cerebras.ai/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "cerebras/qwen-3.8-27b": { "input_cost_per_token": 9.9e-07, "litellm_provider": "cerebras", @@ -14290,23 +13354,6 @@ "mode": "chat", "output_cost_per_token": 5e-07 }, - "chatgpt-4o-latest": { - "deprecation_date": "2026-02-17", - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "gpt-4o-transcribe-diarize": { "input_cost_per_audio_token": 2.5e-06, "input_cost_per_token": 2.5e-06, @@ -14369,131 +13416,6 @@ "prompt_cache_min_tokens": 4096, "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, - "claude-3-7-sonnet-20250219": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-02-19", - "input_cost_per_token": 3e-06, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, - "claude-3-haiku-20240307": { - "cache_creation_input_token_cost": 3e-07, - "cache_creation_input_token_cost_above_1hr": 5e-07, - "cache_read_input_token_cost": 3e-08, - "deprecation_date": "2026-04-20", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "claude-3-opus-20240229": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "deprecation_date": "2026-01-05", - "input_cost_per_token": 1.5e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "claude-4-opus-20250514": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_read_input_token_cost": 1.5e-06, - "deprecation_date": "2026-06-15", - "input_cost_per_token": 1.5e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "claude-4-sonnet-20250514": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost": 3e-07, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "deprecation_date": "2026-06-15", - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "prompt_cache_min_tokens": 1024 - }, "claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, @@ -14672,92 +13594,6 @@ "input_cost_per_token_batches": 1.5e-06, "output_cost_per_token_batches": 7.5e-06 }, - "claude-opus-4-1": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_native_structured_output": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024, - "deprecation_date": "2026-08-05" - }, - "claude-opus-4-1-20250805": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "deprecation_date": "2026-08-05", - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_native_structured_output": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "claude-opus-4-20250514": { - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "deprecation_date": "2026-06-15", - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "claude-opus-4-5-20251101": { "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -15185,38 +14021,6 @@ "prompt_cache_min_tokens": 1024, "source": "https://platform.claude.com/docs/en/about-claude/pricing" }, - "claude-sonnet-4-20250514": { - "deprecation_date": "2026-06-15", - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "cloudflare/@cf/meta/llama-2-7b-chat-fp16": { "input_cost_per_token": 1.923e-06, "litellm_provider": "cloudflare", @@ -15569,36 +14373,6 @@ "supports_assistant_prefill": true, "supports_tool_choice": true }, - "codex-mini-latest": { - "cache_read_input_token_cost": 3.75e-07, - "deprecation_date": "2026-02-12", - "input_cost_per_token": 1.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 6e-06, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "cohere.command-light-text-v14": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", @@ -15609,28 +14383,6 @@ "output_cost_per_token": 6e-07, "supports_tool_choice": true }, - "cohere.command-r-plus-v1:0": { - "deprecation_date": "2026-08-19", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_tool_choice": true - }, - "cohere.command-r-v1:0": { - "deprecation_date": "2026-08-19", - "input_cost_per_token": 5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_tool_choice": true - }, "cohere.command-text-v14": { "input_cost_per_token": 1.5e-06, "litellm_provider": "bedrock", @@ -15720,16 +14472,6 @@ "mode": "rerank", "output_cost_per_token": 0.0 }, - "command": { - "input_cost_per_token": 1e-06, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "completion", - "output_cost_per_token": 2e-06, - "deprecation_date": "2025-09-15" - }, "command-a-03-2025": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", @@ -15756,17 +14498,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "command-light": { - "input_cost_per_token": 3e-07, - "litellm_provider": "cohere_chat", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_tool_choice": true, - "deprecation_date": "2025-09-15" - }, "command-nightly": { "input_cost_per_token": 1e-06, "litellm_provider": "cohere", @@ -15776,18 +14507,6 @@ "mode": "completion", "output_cost_per_token": 2e-06 }, - "command-r": { - "input_cost_per_token": 1.5e-07, - "litellm_provider": "cohere_chat", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_function_calling": true, - "supports_tool_choice": true, - "deprecation_date": "2025-09-15" - }, "command-r-08-2024": { "input_cost_per_token": 1.5e-07, "litellm_provider": "cohere_chat", @@ -15799,18 +14518,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "command-r-plus": { - "input_cost_per_token": 2.5e-06, - "litellm_provider": "cohere_chat", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supports_function_calling": true, - "supports_tool_choice": true, - "deprecation_date": "2025-09-15" - }, "command-r-plus-08-2024": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", @@ -15863,26 +14570,6 @@ "supports_vision": true, "source": "https://platform.openai.com/docs/models/computer-use-preview" }, - "dall-e-2": { - "deprecation_date": "2026-05-12", - "input_cost_per_image": 0.02, - "litellm_provider": "openai", - "mode": "image_generation", - "supported_endpoints": [ - "/v1/images/generations", - "/v1/images/edits", - "/v1/images/variations" - ] - }, - "dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_image": 0.04, - "litellm_provider": "openai", - "mode": "image_generation", - "supported_endpoints": [ - "/v1/images/generations" - ] - }, "deepseek-chat": { "cache_read_input_token_cost": 2.8e-08, "input_cost_per_token": 2.8e-07, @@ -18879,30 +17566,6 @@ "output_vector_size": 1024, "source": "https://www.databricks.com/product/pricing/foundation-model-serving" }, - "databricks/databricks-claude-3-7-sonnet": { - "cache_creation_input_token_cost": 3.74997e-06, - "cache_read_input_token_cost": 3.0002e-07, - "deprecation_date": "2026-04-12", - "input_cost_per_token": 2.9999900000000002e-06, - "input_dbu_cost_per_token": 4.2857e-05, - "litellm_provider": "databricks", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.5000020000000002e-05, - "output_dbu_cost_per_token": 0.000214286, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_anthropic_thinking_payload": true, - "supports_tool_choice": true - }, "databricks/databricks-claude-fable-5": { "cache_creation_input_token_cost": 1.250004e-05, "cache_read_input_token_cost": 1.00002e-06, @@ -19865,44 +18528,6 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, - "databricks/databricks-gpt-5-1-codex-max": { - "cache_creation_input_token_cost": 1.24999e-06, - "cache_read_input_token_cost": 1.2502e-07, - "deprecation_date": "2026-07-16", - "input_cost_per_token": 1.24999e-06, - "input_dbu_cost_per_token": 1.7857e-05, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 9.999990000000002e-06, - "output_dbu_cost_per_token": 0.000142857, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, - "databricks/databricks-gpt-5-1-codex-mini": { - "cache_creation_input_token_cost": 2.4997e-07, - "cache_read_input_token_cost": 2.499e-08, - "deprecation_date": "2026-07-16", - "input_cost_per_token": 2.4997e-07, - "input_dbu_cost_per_token": 3.571e-06, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.99997e-06, - "output_dbu_cost_per_token": 2.8571e-05, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, "databricks/databricks-gpt-5-2": { "cache_creation_input_token_cost": 1.75e-06, "cache_read_input_token_cost": 1.75e-07, @@ -19923,25 +18548,6 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, - "databricks/databricks-gpt-5-2-codex": { - "cache_creation_input_token_cost": 1.75e-06, - "cache_read_input_token_cost": 1.75e-07, - "deprecation_date": "2026-07-16", - "input_cost_per_token": 1.75e-06, - "input_dbu_cost_per_token": 2.5e-05, - "litellm_provider": "databricks", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_dbu_cost_per_token": 0.0002, - "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", - "supports_prompt_caching": true - }, "databricks/databricks-gpt-5-3-codex": { "cache_creation_input_token_cost": 1.75e-06, "cache_read_input_token_cost": 1.75e-07, @@ -20368,25 +18974,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "databricks/databricks-llama-2-70b-chat": { - "cache_creation_input_token_cost": 5.0001e-07, - "cache_read_input_token_cost": 5.0001e-07, - "deprecation_date": "2024-10-30", - "input_cost_per_token": 5.0001e-07, - "input_dbu_cost_per_token": 7.143e-06, - "litellm_provider": "databricks", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.5000300000000002e-06, - "output_dbu_cost_per_token": 2.1429e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, "databricks/databricks-llama-4-maverick": { "cache_creation_input_token_cost": 5.0001e-07, "cache_read_input_token_cost": 5.0001e-07, @@ -20405,25 +18992,6 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, - "databricks/databricks-meta-llama-3-1-405b-instruct": { - "cache_creation_input_token_cost": 5.00003e-06, - "cache_read_input_token_cost": 5.00003e-06, - "deprecation_date": "2026-02-15", - "input_cost_per_token": 5.00003e-06, - "input_dbu_cost_per_token": 7.1429e-05, - "litellm_provider": "databricks", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.5000020000000002e-05, - "output_dbu_cost_per_token": 0.000214286, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, "databricks/databricks-meta-llama-3-1-8b-instruct": { "cache_creation_input_token_cost": 1.5001e-07, "cache_read_input_token_cost": 1.5001e-07, @@ -20459,82 +19027,6 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, - "databricks/databricks-meta-llama-3-70b-instruct": { - "cache_creation_input_token_cost": 1.00002e-06, - "cache_read_input_token_cost": 1.00002e-06, - "deprecation_date": "2024-07-23", - "input_cost_per_token": 1.00002e-06, - "input_dbu_cost_per_token": 1.4286e-05, - "litellm_provider": "databricks", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 2.9999900000000002e-06, - "output_dbu_cost_per_token": 4.2857e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, - "databricks/databricks-mixtral-8x7b-instruct": { - "cache_creation_input_token_cost": 5.0001e-07, - "cache_read_input_token_cost": 5.0001e-07, - "deprecation_date": "2025-04-30", - "input_cost_per_token": 5.0001e-07, - "input_dbu_cost_per_token": 7.143e-06, - "litellm_provider": "databricks", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.00002e-06, - "output_dbu_cost_per_token": 1.4286e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, - "databricks/databricks-mpt-30b-instruct": { - "cache_creation_input_token_cost": 1.00002e-06, - "cache_read_input_token_cost": 1.00002e-06, - "deprecation_date": "2024-08-30", - "input_cost_per_token": 1.00002e-06, - "input_dbu_cost_per_token": 1.4286e-05, - "litellm_provider": "databricks", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 1.00002e-06, - "output_dbu_cost_per_token": 1.4286e-05, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, - "databricks/databricks-mpt-7b-instruct": { - "cache_creation_input_token_cost": 5.0001e-07, - "cache_read_input_token_cost": 5.0001e-07, - "deprecation_date": "2024-08-30", - "input_cost_per_token": 5.0001e-07, - "input_dbu_cost_per_token": 7.143e-06, - "litellm_provider": "databricks", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." - }, - "mode": "chat", - "output_cost_per_token": 0.0, - "output_dbu_cost_per_token": 0.0, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", - "supports_tool_choice": true - }, "databricks/databricks-qwen35-122b-a10b": { "cache_creation_input_token_cost": 2.2001e-07, "cache_read_input_token_cost": 2.2001e-07, @@ -21599,18 +20091,6 @@ "supports_tool_choice": true, "supports_function_calling": true }, - "deepinfra/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-06-01", - "max_tokens": 1000000, - "max_input_tokens": 1000000, - "max_output_tokens": 1000000, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 4e-07, - "litellm_provider": "deepinfra", - "mode": "chat", - "supports_tool_choice": true, - "supports_function_calling": true - }, "deepinfra/google/gemini-2.5-flash": { "max_tokens": 1000000, "max_input_tokens": 1000000, @@ -22469,15 +20949,6 @@ "/v1/audio/speech" ] }, - "embed-english-light-v2.0": { - "deprecation_date": "2026-04-04", - "input_cost_per_token": 1e-07, - "litellm_provider": "cohere", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "embedding", - "output_cost_per_token": 0.0 - }, "embed-english-light-v3.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", @@ -22486,15 +20957,6 @@ "mode": "embedding", "output_cost_per_token": 0.0 }, - "embed-english-v2.0": { - "deprecation_date": "2026-04-04", - "input_cost_per_token": 1e-07, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_tokens": 4096, - "mode": "embedding", - "output_cost_per_token": 0.0 - }, "embed-english-v3.0": { "input_cost_per_image": 0.0001, "input_cost_per_token": 1e-07, @@ -22509,15 +20971,6 @@ "supports_embedding_image_input": true, "supports_image_input": true }, - "embed-multilingual-v2.0": { - "deprecation_date": "2026-04-04", - "input_cost_per_token": 1e-07, - "litellm_provider": "cohere", - "max_input_tokens": 768, - "max_tokens": 768, - "mode": "embedding", - "output_cost_per_token": 0.0 - }, "embed-multilingual-v3.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", @@ -22685,23 +21138,6 @@ "cache_read_input_token_cost": 3e-07, "cache_creation_input_token_cost": 3.75e-06 }, - "eu.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "eu.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", @@ -22717,23 +21153,6 @@ "cache_read_input_token_cost": 1.5e-06, "cache_creation_input_token_cost": 1.875e-05 }, - "eu.anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "eu.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, @@ -25305,26 +23724,6 @@ "supports_tool_choice": true, "supports_vision": false }, - "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro": { - "cache_read_input_token_cost": 6e-07, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 1.2e-06, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/accounts/fireworks/models/deepseek-v4-pro-0813": { "cache_read_input_token_cost": 4.4e-08, "cache_read_input_token_cost_priority": 5.5e-08, @@ -25712,26 +24111,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "fireworks_ai/accounts/fireworks/models/minimax-m2p7": { - "cache_read_input_token_cost": 6e-08, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 3e-07, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 196608, - "max_output_tokens": 196608, - "max_tokens": 196608, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/accounts/fireworks/models/minimax-m3": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 9e-08, @@ -25819,26 +24198,6 @@ "supports_tool_choice": true, "supports_vision": false }, - "fireworks_ai/deepseek-v4-pro": { - "cache_read_input_token_cost": 6e-07, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 1.2e-06, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/glm-4p7": { "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 6e-07, @@ -26038,26 +24397,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "fireworks_ai/minimax-m2p7": { - "cache_read_input_token_cost": 6e-08, - "cache_read_input_token_cost_priority": 6e-07, - "deprecation_date": "2026-08-27", - "input_cost_per_token": 3e-07, - "input_cost_per_token_priority": 1.2e-06, - "litellm_provider": "fireworks_ai", - "max_input_tokens": 196608, - "max_output_tokens": 196608, - "max_tokens": 196608, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_priority": 1.2e-06, - "source": "https://api.fireworks.ai/v1/serverless/models", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false - }, "fireworks_ai/minimax-m3": { "cache_read_input_token_cost": 6e-08, "cache_read_input_token_cost_priority": 9e-08, @@ -26235,31 +24574,6 @@ "comment": "Open flagship GLM for long-horizon coding agents and million-token context work", "source": "https://api.friendli.ai/serverless/v1/models" }, - "friendliai/LGAI-EXAONE/K-EXAONE-2.0-750B-A37B": { - "litellm_provider": "friendliai", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "input_cost_per_token": 6e-07, - "output_cost_per_token": 2.4e-06, - "cache_read_input_token_cost": 1.2e-07, - "supports_prompt_caching": true, - "supports_reasoning": true, - "reasoning_effort_levels": [], - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_native_structured_output": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_image_input": false, - "supports_video_input": false, - "mode": "chat", - "comment": "Frontier-scale multilingual language model developed by LG AI Research", - "deprecation_date": "2026-09-06", - "source": "https://api.friendli.ai/serverless/v1/models" - }, "friendliai/deepseek-ai/DeepSeek-V3.2": { "litellm_provider": "friendliai", "max_input_tokens": 163840, @@ -26565,160 +24879,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "gemini-2.0-flash": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 1e-06, - "input_cost_per_audio_token_batches": 5e-07, - "input_cost_per_character": 3.75e-08, - "input_cost_per_token": 1.5e-07, - "input_cost_per_token_batches": 7.5e-08, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "output_cost_per_token_batches": 3e-07, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini-2.0-flash-001": { - "cache_read_input_token_cost": 3.75e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 1e-06, - "input_cost_per_token": 1.5e-07, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini-2.0-flash-lite": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_audio_token_batches": 3.75e-08, - "input_cost_per_character": 1.875e-08, - "input_cost_per_token": 7.5e-08, - "input_cost_per_token_batches": 3.75e-08, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "output_cost_per_token_batches": 1.5e-07, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini-2.0-flash-lite-001": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_token": 7.5e-08, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini-2.5-flash": { "cache_read_input_audio_token_cost": 1e-07, "deprecation_date": "2026-10-20", @@ -26830,6 +24990,7 @@ "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, "deprecation_date": "2027-05-28", @@ -26878,7 +25039,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_read_input_token_cost_batches": 1e-07 }, "gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -26922,6 +25084,7 @@ }, "gemini-3.1-flash-image": { "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, @@ -26963,7 +25126,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "cache_read_input_token_cost_batches": 2.5e-08 }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -27005,6 +25169,7 @@ }, "gemini-3.1-flash-lite-image": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, @@ -27042,7 +25207,8 @@ "supports_response_schema": false, "supports_system_messages": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -27097,6 +25263,7 @@ "cache_read_input_audio_token_cost": 5e-08, "deprecation_date": "2027-05-07", "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_audio_token": 5e-07, @@ -27151,11 +25318,13 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_audio_token_batches": 2.5e-07 + "input_cost_per_audio_token_batches": 2.5e-07, + "cache_read_input_token_cost_batches": 1.25e-08 }, "gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_token": 3e-07, @@ -27208,10 +25377,12 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 1.5e-08 }, "deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_batches": 1e-07, "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, @@ -27243,7 +25414,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "cache_read_input_token_cost_batches": 1e-07 }, "gemini-2.5-flash-lite": { "cache_read_input_audio_token_cost": 3e-08, @@ -27531,53 +25703,6 @@ }, "gemini_native_audio": true }, - "gemini-2.5-flash-lite-preview-06-17": { - "deprecation_date": "2025-11-18", - "cache_read_input_token_cost": 1e-08, - "input_cost_per_audio_token": 5e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 4e-07, - "output_cost_per_token": 4e-07, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, "gemini-2.5-pro": { "deprecation_date": "2026-10-20", "cache_read_input_token_cost": 1.25e-07, @@ -27636,62 +25761,6 @@ "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 1.8e-05 }, - "gemini-3-pro-preview": { - "deprecation_date": "2026-03-26", - "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, - "input_cost_per_token": 2e-06, - "input_cost_per_token_above_200k_tokens": 4e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "vertex_ai-language-models", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_token": 1.2e-05, - "output_cost_per_token_above_200k_tokens": 1.8e-05, - "output_cost_per_token_batches": 6e-06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "supports_native_streaming": true, - "input_cost_per_token_priority": 3.6e-06, - "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, - "output_cost_per_token_priority": 2.16e-05, - "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, - "cache_read_input_token_cost_priority": 3.6e-07, - "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, "gemini-3.1-pro-preview": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 2e-07, @@ -27922,6 +25991,7 @@ "prompt_cache_min_tokens": 4096, "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_batches": 7.5e-08, "input_cost_per_token": 1.5e-06, "input_cost_per_audio_token": 1.5e-06, "litellm_provider": "vertex_ai", @@ -27976,11 +26046,13 @@ "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_batches": 7.5e-08 }, "vertex_ai/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -28033,11 +26105,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "vertex_ai/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -28091,11 +26165,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "vertex_ai/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -28149,7 +26225,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "vertex_ai/gemini-3.1-pro-preview": { "prompt_cache_min_tokens": 4096, @@ -28342,53 +26419,9 @@ "supports_url_context": true, "supports_vision": true }, - "gemini/gemini-robotics-er-1.5-preview": { - "cache_read_input_token_cost": 0, - "deprecation_date": "2026-04-30", - "input_cost_per_token": 3e-07, - "input_cost_per_audio_token": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "output_cost_per_reasoning_token": 2.5e-06, - "source": "https://ai.google.dev/gemini-api/docs/models#gemini-robotics-er-1-5-preview", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions" - ], - "supported_modalities": [ - "text", - "image", - "video", - "audio" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": false, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "rpm": 10, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini/gemini-robotics-er-2-preview": { "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_batches": 5e-08, "input_cost_per_audio_token": 2e-06, "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, @@ -28436,53 +26469,6 @@ "supports_web_search": true, "web_search_billing_unit": "per_query" }, - "gemini/gemini-robotics-er-1.6-preview": { - "deprecation_date": "2026-08-31", - "input_cost_per_audio_token": 2e-06, - "input_cost_per_token": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 131072, - "max_output_tokens": 65536, - "max_tokens": 65536, - "mode": "chat", - "output_cost_per_reasoning_token": 5e-06, - "output_cost_per_token": 5e-06, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-robotics-er", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "web_search_billing_unit": "per_query" - }, "gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, @@ -28619,27 +26605,6 @@ "source": "https://ai.google.dev/gemini-api/docs/embeddings#model-versions", "tpm": 10000000 }, - "gemini/gemini-embedding-2-preview": { - "deprecation_date": "2026-08-10", - "input_cost_per_audio_token": 6.5e-06, - "input_cost_per_audio_token_batches": 3.25e-06, - "input_cost_per_image_token": 4.5e-07, - "input_cost_per_image_token_batches": 2.25e-07, - "input_cost_per_token": 2e-07, - "input_cost_per_token_batches": 1e-07, - "input_cost_per_video_token": 1.2e-05, - "input_cost_per_video_token_batches": 6e-06, - "litellm_provider": "gemini", - "max_input_tokens": 8192, - "max_tokens": 8192, - "mode": "embedding", - "output_cost_per_token": 0, - "output_vector_size": 3072, - "rpm": 10000, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supports_multimodal": true, - "tpm": 10000000 - }, "gemini/gemini-embedding-2": { "input_cost_per_audio_token": 6.5e-06, "input_cost_per_audio_token_batches": 3.25e-06, @@ -28662,138 +26627,10 @@ "supports_vision": true, "tpm": 10000000 }, - "gemini/gemini-1.5-flash": { - "deprecation_date": "2025-09-29", - "input_cost_per_token": 7.5e-08, - "input_cost_per_token_above_128k_tokens": 1.5e-07, - "litellm_provider": "gemini", - "max_input_tokens": 8192, - "max_tokens": 8192, - "mode": "embedding", - "output_cost_per_token": 0, - "output_vector_size": 3072, - "rpm": 10000, - "source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal", - "supports_multimodal": true, - "tpm": 10000000 - }, - "gemini/gemini-2.0-flash": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-07, - "rpm": 10000, - "source": "https://ai.google.dev/pricing#2_0flash", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 10000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini/gemini-2.0-flash-001": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-07, - "rpm": 10000, - "source": "https://ai.google.dev/pricing#2_0flash", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 10000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, - "gemini/gemini-2.0-flash-lite": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_token": 7.5e-08, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "rpm": 4000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.0-flash-lite", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 4000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini/gemini-2.5-flash": { "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 3e-08, "cache_read_input_token_cost_flex": 3e-08, "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_audio_token": 1e-06, @@ -28952,50 +26789,6 @@ "web_search_billing_unit": "per_query", "supports_reasoning": false }, - "gemini/gemini-3-pro-image-preview": { - "deprecation_date": "2026-06-25", - "input_cost_per_image": 0.0011, - "input_cost_per_token": 2e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 65536, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "image_generation", - "output_cost_per_image": 0.134, - "output_cost_per_image_token": 0.00012, - "output_cost_per_token": 1.2e-05, - "rpm": 1000, - "tpm": 4000000, - "output_cost_per_token_batches": 6e-06, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, "gemini/nano-banana-pro-preview": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -29081,49 +26874,6 @@ }, "web_search_billing_unit": "per_query" }, - "gemini/gemini-3.1-flash-image-preview": { - "deprecation_date": "2026-06-25", - "input_cost_per_token": 5e-07, - "input_cost_per_token_batches": 2.5e-07, - "litellm_provider": "gemini", - "max_input_tokens": 65536, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "image_generation", - "output_cost_per_image": 0.045, - "output_cost_per_image_token": 6e-05, - "output_cost_per_token": 3e-06, - "output_cost_per_token_batches": 1.5e-06, - "rpm": 1000, - "tpm": 4000000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_vision": true, - "supports_web_search": true, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, "gemini/gemini-3.1-flash-lite-image": { "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, @@ -29205,6 +26955,7 @@ "gemini/gemini-2.5-flash-lite": { "cache_read_input_audio_token_cost": 3e-08, "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_batches": 1e-08, "cache_read_input_token_cost_flex": 1e-08, "cache_read_input_token_cost_priority": 1.8e-08, "input_cost_per_audio_token": 3e-07, @@ -29261,104 +27012,6 @@ "supports_audio_input": true, "supports_image_size": false }, - "gemini/gemini-2.5-flash-lite-preview-09-2025": { - "cache_read_input_token_cost": 1e-08, - "deprecation_date": "2026-03-31", - "input_cost_per_audio_token": 3e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 4e-07, - "output_cost_per_token": 4e-07, - "rpm": 15, - "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, - "gemini/gemini-2.5-flash-preview-09-2025": { - "cache_read_input_token_cost": 3e-08, - "deprecation_date": "2026-02-17", - "input_cost_per_audio_token": 1e-06, - "input_cost_per_token": 3e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 2.5e-06, - "output_cost_per_token": 2.5e-06, - "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 7.5e-07, @@ -29476,55 +27129,6 @@ "supports_video_input": true, "web_search_billing_unit": "per_query" }, - "gemini/gemini-2.5-flash-lite-preview-06-17": { - "deprecation_date": "2025-11-18", - "cache_read_input_token_cost": 1e-08, - "input_cost_per_audio_token": 5e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_reasoning_token": 4e-07, - "output_cost_per_token": 4e-07, - "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - }, - "google_maps_grounding_cost_per_query": 0.025, - "supports_image_size": false - }, "gemini/gemini-2.5-flash-preview-tts": { "input_cost_per_token": 5e-07, "input_cost_per_token_batches": 2.5e-07, @@ -29550,6 +27154,7 @@ "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 4.5e-07, + "cache_read_input_token_cost_batches": 1.25e-07, "cache_read_input_token_cost_flex": 1.25e-07, "cache_read_input_token_cost_priority": 2.25e-07, "input_cost_per_token": 1.25e-06, @@ -29634,117 +27239,10 @@ "supports_vision": true, "tpm": 800000 }, - "gemini/gemini-3-pro-preview": { - "deprecation_date": "2026-03-09", - "cache_read_input_token_cost": 2e-07, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "input_cost_per_token": 2e-06, - "input_cost_per_token_above_200k_tokens": 4e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65535, - "max_tokens": 65535, - "mode": "chat", - "output_cost_per_token": 1.2e-05, - "output_cost_per_token_above_200k_tokens": 1.8e-05, - "output_cost_per_token_batches": 6e-06, - "rpm": 2000, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 800000, - "input_cost_per_token_priority": 3.6e-06, - "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, - "output_cost_per_token_priority": 2.16e-05, - "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, - "cache_read_input_token_cost_priority": 3.6e-07, - "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query" - }, - "gemini/gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-05-25", - "input_cost_per_audio_token": 5e-07, - "input_cost_per_token": 2.5e-07, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 65536, - "max_tokens": 65536, - "mode": "chat", - "output_cost_per_reasoning_token": 1.5e-06, - "output_cost_per_token": 1.5e-06, - "rpm": 15, - "source": "https://ai.google.dev/gemini-api/docs/models", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_input": true, - "supports_audio_output": false, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_url_context": true, - "supports_video_input": true, - "supports_vision": true, - "supports_web_search": true, - "supports_native_streaming": true, - "tpm": 250000, - "search_context_cost_per_query": { - "search_context_size_low": 0.014, - "search_context_size_medium": 0.014, - "search_context_size_high": 0.014 - }, - "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 - }, "gemini/gemini-3.1-flash-lite": { "cache_read_input_audio_token_cost": 5e-08, "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2027-05-07", @@ -29806,6 +27304,7 @@ }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 2e-08, "cache_read_input_token_cost_flex": 2e-08, "cache_read_input_token_cost_priority": 5e-08, "input_cost_per_token": 3e-07, @@ -29865,6 +27364,7 @@ "gemini/gemini-3-flash-preview": { "cache_read_input_audio_token_cost": 1e-07, "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_batches": 5e-08, "cache_read_input_token_cost_flex": 5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 5e-07, @@ -29926,6 +27426,7 @@ "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_batches": 7.5e-08, "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "gemini", @@ -29987,6 +27488,7 @@ "gemini/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30046,6 +27548,7 @@ "gemini/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30106,6 +27609,7 @@ "gemini/gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30248,6 +27752,7 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, @@ -30310,6 +27815,7 @@ "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, "cache_read_input_token_cost_priority": 3.6e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 2e-07, "cache_read_input_token_cost_flex": 2e-07, "search_context_cost_per_query": { "search_context_size_low": 0.014, @@ -30414,6 +27920,7 @@ "prompt_cache_min_tokens": 4096, "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_batches": 7.5e-08, "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai-language-models", @@ -30468,11 +27975,13 @@ "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, "output_cost_per_token_flex": 4.5e-06, - "cache_read_input_token_cost_flex": 7.5e-08 + "cache_read_input_token_cost_flex": 7.5e-08, + "cache_read_input_token_cost_batches": 7.5e-08 }, "gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30525,11 +28034,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30583,11 +28094,13 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "gemini-3.8-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_batches": 3.75e-08, "cache_read_input_token_cost_flex": 3.75e-08, "input_cost_per_token": 7.5e-07, "input_cost_per_token_batches": 3.75e-07, @@ -30641,7 +28154,8 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 3.75e-08 }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -30825,34 +28339,6 @@ "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, - "gemini/imagen-3.0-generate-002": { - "deprecation_date": "2025-11-10", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "gemini/imagen-4.0-fast-generate-001": { - "deprecation_date": "2026-08-17", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "gemini/imagen-4.0-generate-001": { - "deprecation_date": "2026-08-17", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "gemini/imagen-4.0-ultra-generate-001": { - "deprecation_date": "2026-08-17", - "litellm_provider": "gemini", - "mode": "image_generation", - "output_cost_per_image": 0.06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, "gemini/learnlm-1.5-pro-experimental": { "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, @@ -30931,21 +28417,6 @@ "supports_web_search": false, "output_cost_per_image": 0.08 }, - "gemini/veo-2.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "gemini", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.35, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, "gemini/veo-3.1-fast-generate-preview": { "litellm_provider": "gemini", "max_input_tokens": 1024, @@ -31805,10 +29276,21 @@ "output_cost_per_token": 3.15e-06 }, "baseten/zai-org/GLM-4.7": { + "cache_read_input_token_cost": 1.2e-07, "input_cost_per_token": 6e-07, "litellm_provider": "baseten", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, "mode": "chat", - "output_cost_per_token": 2.2e-06 + "output_cost_per_token": 2.2e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false }, "baseten/zai-org/GLM-4.6": { "input_cost_per_token": 6e-07, @@ -31835,10 +29317,21 @@ "output_cost_per_token": 2.5e-06 }, "baseten/openai/gpt-oss-120b": { + "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-07, "litellm_provider": "baseten", + "max_input_tokens": 128072, + "max_output_tokens": 128072, + "max_tokens": 128072, "mode": "chat", - "output_cost_per_token": 5e-07 + "output_cost_per_token": 5e-07, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false }, "baseten/deepseek-ai/DeepSeek-V3.1": { "input_cost_per_token": 5e-07, @@ -32133,33 +29626,6 @@ "supports_system_messages": true, "supports_tool_choice": true }, - "gpt-4-0125-preview": { - "deprecation_date": "2026-03-26", - "input_cost_per_token": 1e-05, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 3e-05, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, - "gpt-4-0314": { - "deprecation_date": "2026-03-26", - "input_cost_per_token": 3e-05, - "litellm_provider": "openai", - "max_input_tokens": 8192, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 6e-05, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4-0613": { "deprecation_date": "2026-10-23", "input_cost_per_token": 3e-05, @@ -32229,22 +29695,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "gpt-4-turbo-preview": { - "deprecation_date": "2026-03-26", - "input_cost_per_token": 1e-05, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 3e-05, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4.1": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 8.75e-07, @@ -32587,24 +30037,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "gpt-4o-audio-preview": { - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 2.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 1e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-audio-preview-2024-12-17": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 4e-05, @@ -32789,44 +30221,6 @@ "supports_tool_choice": true, "supports_vision": false }, - "gpt-audio-mini-2025-10-06": { - "deprecation_date": "2026-07-23", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 2.4e-06, - "source": "https://developers.openai.com/api/docs/pricing", - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses", - "/v1/realtime", - "/v1/batch" - ], - "supported_modalities": [ - "text", - "audio" - ], - "supported_output_modalities": [ - "text", - "audio" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": false, - "supports_reasoning": false, - "supports_response_schema": false, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": false - }, "gpt-audio-mini-2025-12-15": { "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, @@ -32923,24 +30317,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "gpt-4o-mini-audio-preview": { - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_token": 1.5e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 6e-07, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-mini-audio-preview-2024-12-17": { "deprecation_date": "2027-01-20", "input_cost_per_audio_token": 1e-05, @@ -32959,26 +30335,6 @@ "supports_system_messages": true, "supports_tool_choice": true }, - "gpt-4o-mini-realtime-preview": { - "cache_creation_input_audio_token_cost": 3e-07, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 2.4e-06, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-mini-realtime-preview-2024-12-17": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 3e-07, @@ -33025,32 +30381,6 @@ "supports_vision": true, "supports_web_search": true }, - "gpt-4o-mini-search-preview-2025-03-11": { - "cache_read_input_token_cost": 7.5e-08, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.5e-07, - "input_cost_per_token_batches": 7.5e-08, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 6e-07, - "output_cost_per_token_batches": 3e-07, - "search_context_cost_per_query": { - "search_context_size_high": 0.025, - "search_context_size_low": 0.025, - "search_context_size_medium": 0.025 - }, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "gpt-4o-mini-transcribe": { "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, @@ -33085,63 +30415,6 @@ "audio" ] }, - "gpt-4o-realtime-preview": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 2e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, - "gpt-4o-realtime-preview-2024-12-17": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 2e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, - "gpt-4o-realtime-preview-2025-06-03": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-05-07", - "input_cost_per_audio_token": 4e-05, - "input_cost_per_token": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 8e-05, - "output_cost_per_token": 2e-05, - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-4o-search-preview": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, @@ -33168,32 +30441,6 @@ "supports_vision": true, "supports_web_search": true }, - "gpt-4o-search-preview-2025-03-11": { - "cache_read_input_token_cost": 1.25e-06, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2.5e-06, - "input_cost_per_token_batches": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "output_cost_per_token_batches": 5e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.025, - "search_context_size_low": 0.025, - "search_context_size_medium": 0.025 - }, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "gpt-4o-transcribe": { "input_cost_per_audio_token": 2.5e-06, "input_cost_per_token": 2.5e-06, @@ -33856,52 +31103,6 @@ "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": false }, - "gpt-5.1-chat-latest": { - "cache_read_input_token_cost": 1.25e-07, - "cache_read_input_token_cost_priority": 2.5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "input_cost_per_token_priority": 2.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "output_cost_per_token_priority": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text", - "image" - ], - "supports_function_calling": false, - "supports_native_streaming": true, - "supports_parallel_function_calling": false, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": false, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": true, - "default_reasoning_effort": "none", - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, "gpt-5.2": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_batches": 8.75e-08, @@ -34008,94 +31209,6 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, - "gpt-5.2-chat-latest": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-08-10", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.3-chat-latest": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-08-10", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, "gpt-5.2-pro": { "input_cost_per_token": 2.1e-05, "input_cost_per_token_batches": 1.05e-05, @@ -34256,6 +31369,158 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens_flex": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, + "cache_creation_input_token_cost_flex": 1.25e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "cache_read_input_token_cost_above_272k_tokens_flex": 2e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 8e-07, + "cache_read_input_token_cost_batches": 1e-07, + "cache_read_input_token_cost_above_272k_tokens_batches": 2e-07, + "cache_creation_input_token_cost_batches": 1.25e-06, + "cache_creation_input_token_cost_above_272k_tokens_batches": 2.5e-06, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "input_cost_per_token_above_272k_tokens_flex": 2e-06, + "input_cost_per_token_above_272k_tokens_priority": 8e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_above_272k_tokens_batches": 2e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 4e-06, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "output_cost_per_token_above_272k_tokens_flex": 7.5e-06, + "output_cost_per_token_above_272k_tokens_priority": 3e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_above_272k_tokens_batches": 7.5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "source": "https://developers.openai.com/api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, + "gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens_flex": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 5e-07, + "cache_creation_input_token_cost_flex": 6.25e-08, + "cache_creation_input_token_cost_priority": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "cache_read_input_token_cost_above_272k_tokens_flex": 1e-08, + "cache_read_input_token_cost_above_272k_tokens_priority": 4e-08, + "cache_read_input_token_cost_batches": 5e-09, + "cache_read_input_token_cost_above_272k_tokens_batches": 1e-08, + "cache_creation_input_token_cost_batches": 6.25e-08, + "cache_creation_input_token_cost_above_272k_tokens_batches": 1.25e-07, + "cache_read_input_token_cost_flex": 5e-09, + "cache_read_input_token_cost_priority": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "input_cost_per_token_above_272k_tokens_flex": 1e-07, + "input_cost_per_token_above_272k_tokens_priority": 4e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_above_272k_tokens_batches": 1e-07, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 2e-07, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, + "output_cost_per_token_above_272k_tokens_flex": 3.75e-07, + "output_cost_per_token_above_272k_tokens_priority": 1.5e-06, + "output_cost_per_token_batches": 2.5e-07, + "output_cost_per_token_above_272k_tokens_batches": 3.75e-07, + "output_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_priority": 1e-06, + "regional_processing_uplift_multiplier_eu": 1.1, + "regional_processing_uplift_multiplier_us": 1.1, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "source": "https://developers.openai.com/api/docs/pricing", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_cache_breakpoint": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "gpt-5.6": { "cache_creation_input_token_cost": 5e-06, "cache_creation_input_token_cost_above_272k_tokens": 1e-05, @@ -35522,6 +32787,7 @@ }, "gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, + "cache_read_input_token_cost_batches": 6.25e-08, "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "deprecation_date": "2026-12-11", @@ -35569,7 +32835,8 @@ "supports_web_search": true, "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 6.25e-08 }, "gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, @@ -35606,251 +32873,6 @@ "supports_xhigh_reasoning_effort": false, "supports_minimal_reasoning_effort": true }, - "gpt-5-chat-latest": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": false, - "supports_native_streaming": true, - "supports_parallel_function_calling": false, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": false, - "supports_vision": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5-codex": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.1-codex": { - "cache_read_input_token_cost": 1.25e-07, - "cache_read_input_token_cost_priority": 2.5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "input_cost_per_token_priority": 2.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1e-05, - "output_cost_per_token_priority": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.1-codex-max": { - "cache_read_input_token_cost": 1.25e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.1-codex-mini": { - "cache_read_input_token_cost": 2.5e-08, - "cache_read_input_token_cost_priority": 4.5e-08, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2.5e-07, - "input_cost_per_token_priority": 4.5e-07, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 2e-06, - "output_cost_per_token_priority": 3.6e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true - }, - "gpt-5.2-codex": { - "cache_read_input_token_cost": 1.75e-07, - "cache_read_input_token_cost_priority": 3.5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1.75e-06, - "input_cost_per_token_priority": 3.5e-06, - "litellm_provider": "openai", - "max_input_tokens": 272000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "responses", - "output_cost_per_token": 1.4e-05, - "output_cost_per_token_priority": 2.8e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "supports_none_reasoning_effort": false, - "supports_xhigh_reasoning_effort": true, - "supports_minimal_reasoning_effort": true - }, "gpt-5.3-codex": { "cache_read_input_token_cost": 1.75e-07, "cache_read_input_token_cost_priority": 3.5e-07, @@ -35947,6 +32969,7 @@ }, "gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "deprecation_date": "2026-12-11", @@ -35994,7 +33017,8 @@ "supports_web_search": true, "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "gpt-5-nano": { "cache_read_input_token_cost": 5e-09, @@ -36047,6 +33071,7 @@ }, "gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5e-09, + "cache_read_input_token_cost_batches": 2.5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "deprecation_date": "2026-12-11", "input_cost_per_token": 5e-08, @@ -36092,7 +33117,8 @@ "supports_web_search": true, "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false, - "supports_minimal_reasoning_effort": true + "supports_minimal_reasoning_effort": true, + "cache_read_input_token_cost_batches": 2.5e-09 }, "gpt-image-1": { "cache_read_input_token_cost": 1.25e-06, @@ -36689,44 +33715,14 @@ "supports_response_schema": true, "supports_vision": true }, - "groq/llama-3.1-8b-instant": { - "deprecation_date": "2026-08-16", - "input_cost_per_token": 5e-08, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 8e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/llama-3.3-70b-versatile": { - "deprecation_date": "2026-08-16", - "input_cost_per_token": 5.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 7.9e-07, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, - "groq/gemma-7b-it": { - "deprecation_date": "2024-12-18", - "input_cost_per_token": 5e-08, + "groq/llama-guard-3-8b": { + "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 8192, - "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", - "output_cost_per_token": 8e-08, - "supports_function_calling": true, - "supports_response_schema": false, - "supports_tool_choice": true + "output_cost_per_token": 2e-07, + "source": "https://console.groq.com/docs/model/llama-guard-3-8b" }, "groq/meta-llama/llama-prompt-guard-2-22m": { "input_cost_per_token": 3e-08, @@ -36748,58 +33744,6 @@ "output_cost_per_token": 4e-08, "source": "https://console.groq.com/docs/model/meta-llama/llama-prompt-guard-2-86m" }, - "groq/meta-llama/llama-guard-4-12b": { - "deprecation_date": "2026-03-05", - "input_cost_per_token": 2e-07, - "litellm_provider": "groq", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-07 - }, - "groq/meta-llama/llama-4-maverick-17b-128e-instruct": { - "deprecation_date": "2026-03-09", - "input_cost_per_token": 2e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/meta-llama/llama-4-scout-17b-16e-instruct": { - "deprecation_date": "2026-07-17", - "input_cost_per_token": 1.1e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3.4e-07, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/moonshotai/kimi-k2-instruct-0905": { - "deprecation_date": "2026-04-15", - "input_cost_per_token": 1e-06, - "output_cost_per_token": 3e-06, - "cache_read_input_token_cost": 5e-07, - "litellm_provider": "groq", - "max_input_tokens": 262144, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "groq/openai/gpt-oss-120b": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.5e-07, @@ -36880,45 +33824,6 @@ "mode": "audio_speech", "source": "https://console.groq.com/docs/models" }, - "groq/playai-tts": { - "deprecation_date": "2025-12-31", - "input_cost_per_character": 5e-05, - "litellm_provider": "groq", - "max_input_tokens": 10000, - "max_output_tokens": 10000, - "max_tokens": 10000, - "mode": "audio_speech" - }, - "groq/qwen/qwen3.6-27b": { - "input_cost_per_token": 6e-07, - "litellm_provider": "groq", - "max_input_tokens": 131072, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://console.groq.com/docs/model/qwen/qwen3.6-27b", - "deprecation_date": "2026-09-14", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true - }, - "groq/qwen/qwen3-32b": { - "deprecation_date": "2026-07-17", - "input_cost_per_token": 2.9e-07, - "litellm_provider": "groq", - "max_input_tokens": 131000, - "max_output_tokens": 131000, - "max_tokens": 131000, - "mode": "chat", - "output_cost_per_token": 5.9e-07, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true - }, "groq/whisper-large-v3": { "input_cost_per_second": 3.083e-05, "litellm_provider": "groq", @@ -36931,27 +33836,6 @@ "mode": "audio_transcription", "output_cost_per_second": 0.0 }, - "hd/1024-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 7.629e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "hd/1024-x-1792/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 6.539e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "hd/1792-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 6.539e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "heroku/claude-3-5-haiku": { "litellm_provider": "heroku", "max_tokens": 8192, @@ -38778,19 +35662,6 @@ "supports_system_messages": true, "supports_native_structured_output": true }, - "mistral/codestral-2405": { - "deprecation_date": "2025-06-16", - "input_cost_per_token": 1e-06, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 3e-06, - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/codestral-2508": { "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-07, @@ -38834,51 +35705,6 @@ "supports_assistant_prefill": true, "supports_tool_choice": true }, - "mistral/devstral-medium-2507": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/devstral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/devstral-small-2505": { - "deprecation_date": "2025-11-30", - "input_cost_per_token": 1e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://mistral.ai/news/devstral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/devstral-small-2507": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 1e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://mistral.ai/news/devstral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/devstral-small-latest": { "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, @@ -38894,21 +35720,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/labs-devstral-small-2512": { - "deprecation_date": "2026-03-31", - "input_cost_per_token": 1e-07, - "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://docs.mistral.ai/models/devstral-small-2-25-12", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/devstral-latest": { "cache_read_input_token_cost": 4e-08, "input_cost_per_token": 4e-07, @@ -38939,21 +35750,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/devstral-2512": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/devstral-2-vibe-cli", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/ministral-14b-2512": { "input_cost_per_token": 2e-07, "litellm_provider": "mistral", @@ -39229,54 +36025,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/magistral-medium-2506": { - "deprecation_date": "2025-11-30", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://mistral.ai/news/magistral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/magistral-medium-2509": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://mistral.ai/news/magistral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/magistral-medium-1-2-2509": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://mistral.ai/news/magistral", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.004, @@ -39316,20 +36064,6 @@ "/v1/batch" ] }, - "mistral/mistral-ocr-2505-completion": { - "deprecation_date": "2026-05-31", - "litellm_provider": "mistral", - "ocr_cost_per_page": 0.001, - "ocr_cost_per_page_batches": 0.0005, - "annotation_cost_per_page": 0.003, - "annotation_cost_per_page_batches": 0.0015, - "mode": "ocr", - "supported_endpoints": [ - "/v1/ocr", - "/v1/batch" - ], - "source": "https://mistral.ai/pricing#api-pricing" - }, "mistral/mistral-ocr-2512": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.002, @@ -39360,22 +36094,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/magistral-small-2506": { - "deprecation_date": "2025-11-30", - "input_cost_per_token": 5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://mistral.ai/pricing#api-pricing", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/magistral-small-latest": { "cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 1.5e-07, @@ -39393,22 +36111,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/magistral-small-1-2-2509": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 40000, - "max_output_tokens": 40000, - "max_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://mistral.ai/pricing#api-pricing", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/mistral-embed": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", @@ -39432,48 +36134,6 @@ "max_tokens": 8192, "mode": "embedding" }, - "mistral/mistral-large-2402": { - "deprecation_date": "2025-06-16", - "input_cost_per_token": 4e-06, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 1.2e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-large-2407": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 9e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-large-2411": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/mistral-large-latest": { "cache_read_input_token_cost": 5e-08, "input_cost_per_token": 5e-07, @@ -39543,49 +36203,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/mistral-medium-2312": { - "deprecation_date": "2025-06-16", - "input_cost_per_token": 2.7e-06, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 8.1e-06, - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-medium-2505": { - "deprecation_date": "2026-08-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 2e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/mistral-medium-2508": { - "deprecation_date": "2026-08-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/mistral-medium-3", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/mistral-medium-2604": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.5e-06, @@ -39628,22 +36245,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/mistral-medium-3-1-2508": { - "deprecation_date": "2026-08-31", - "input_cost_per_token": 4e-07, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://mistral.ai/news/mistral-medium-3", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/mistral-medium-3-5": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.5e-06, @@ -39700,22 +36301,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "mistral/mistral-small-3-2-2506": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 6e-08, - "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://mistral.ai/pricing", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/ministral-3-3b-2512": { "cache_read_input_token_cost": 1e-08, "input_cost_per_token": 1e-07, @@ -39809,32 +36394,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/open-codestral-mamba": { - "deprecation_date": "2025-06-06", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-07, - "source": "https://mistral.ai/technology/", - "supports_assistant_prefill": true, - "supports_tool_choice": true - }, - "mistral/open-mistral-7b": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 2.5e-07, - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "mistral/open-mistral-nemo": { "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 3e-07, @@ -39849,78 +36408,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "mistral/open-mistral-nemo-2407": { - "deprecation_date": "2026-07-31", - "input_cost_per_token": 3e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://mistral.ai/technology/", - "supports_assistant_prefill": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/open-mixtral-8x22b": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 65336, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 6e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/open-mixtral-8x7b": { - "deprecation_date": "2025-03-30", - "input_cost_per_token": 7e-07, - "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, - "mode": "chat", - "output_cost_per_token": 7e-07, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "mistral/pixtral-12b-2409": { - "deprecation_date": "2025-12-31", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 1.5e-07, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "mistral/pixtral-large-2411": { - "deprecation_date": "2026-05-31", - "input_cost_per_token": 2e-06, - "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "mistral/pixtral-large-latest": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 2e-06, @@ -39969,36 +36456,6 @@ "supports_audio_input": false, "supports_response_schema": true }, - "moonshot/kimi-k2-0711-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_web_search": true - }, - "moonshot/kimi-k2-0905-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "moonshot/kimi-k2.7-code": { "cache_read_input_token_cost": 1.9e-07, "input_cost_per_token": 9.5e-07, @@ -40017,21 +36474,6 @@ "supports_video_input": true, "supports_vision": true }, - "moonshot/kimi-k2-turbo-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 1.15e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 8e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "moonshot/kimi-k2.5": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 6e-07, @@ -40088,111 +36530,6 @@ "supports_video_input": true, "supports_vision": true }, - "moonshot/kimi-latest": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 2e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-latest-128k": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 2e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-latest-32k": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 1e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-latest-8k": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-01-28", - "input_cost_per_token": 2e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "moonshot/kimi-thinking-preview": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2025-11-11", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_vision": true - }, - "moonshot/kimi-k2-thinking": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 6e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "supports_web_search": true - }, - "moonshot/kimi-k2-thinking-turbo": { - "cache_read_input_token_cost": 1.5e-07, - "deprecation_date": "2026-05-25", - "input_cost_per_token": 1.15e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 8e-06, - "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "supports_web_search": true - }, "moonshot/moonshot-v1-128k": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", @@ -40206,19 +36543,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "moonshot/moonshot-v1-128k-0430": { - "deprecation_date": "2024-04-30", - "input_cost_per_token": 2e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 5e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true - }, "moonshot/moonshot-v1-128k-vision-preview": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", @@ -40246,19 +36570,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "moonshot/moonshot-v1-32k-0430": { - "deprecation_date": "2024-04-30", - "input_cost_per_token": 1e-06, - "litellm_provider": "moonshot", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true - }, "moonshot/moonshot-v1-32k-vision-preview": { "input_cost_per_token": 1e-06, "litellm_provider": "moonshot", @@ -40286,19 +36597,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "moonshot/moonshot-v1-8k-0430": { - "deprecation_date": "2024-04-30", - "input_cost_per_token": 2e-07, - "litellm_provider": "moonshot", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://platform.moonshot.ai/docs/pricing", - "supports_function_calling": true, - "supports_tool_choice": true - }, "moonshot/moonshot-v1-8k-vision-preview": { "input_cost_per_token": 2e-07, "litellm_provider": "moonshot", @@ -41489,88 +37787,6 @@ "supports_vision": true, "supports_web_search": true }, - "o3-deep-research": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1e-05, - "input_cost_per_token_batches": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 4e-05, - "output_cost_per_token_batches": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, - "o3-deep-research-2025-06-26": { - "cache_read_input_token_cost": 2.5e-06, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 1e-05, - "input_cost_per_token_batches": 5e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 4e-05, - "output_cost_per_token_batches": 2e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, "o3-mini": { "cache_read_input_token_cost": 5.5e-07, "deprecation_date": "2026-10-23", @@ -41756,88 +37972,6 @@ "supports_vision": true, "supports_web_search": true }, - "o4-mini-deep-research": { - "cache_read_input_token_cost": 5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 8e-06, - "output_cost_per_token_batches": 4e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, - "o4-mini-deep-research-2025-06-26": { - "cache_read_input_token_cost": 5e-07, - "deprecation_date": "2026-07-23", - "input_cost_per_token": 2e-06, - "input_cost_per_token_batches": 1e-06, - "litellm_provider": "openai", - "max_input_tokens": 200000, - "max_output_tokens": 100000, - "max_tokens": 100000, - "mode": "responses", - "output_cost_per_token": 8e-06, - "output_cost_per_token_batches": 4e-06, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supported_endpoints": [ - "/v1/chat/completions", - "/v1/batch", - "/v1/responses" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "text" - ], - "supports_function_calling": true, - "supports_native_streaming": true, - "supports_parallel_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true - }, "oci/meta.llama-3.1-8b-instruct": { "input_cost_per_token": 7.2e-07, "litellm_provider": "oci", @@ -42810,32 +38944,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "openrouter/anthropic/claude-opus-4": { - "input_cost_per_image": 0.0048, - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "openrouter", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_pdf_input": true, - "supports_response_schema": false, - "supports_web_search": true - }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 1.875e-05, @@ -43089,6 +39197,28 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "openrouter/anthropic/claude-opus-5.5": { + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "openrouter/bytedance/ui-tars-1.5-7b": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-07, @@ -43260,21 +39390,21 @@ "supports_web_search": false }, "openrouter/deepseek/deepseek-v4-pro": { - "input_cost_per_token": 9.31074e-07, + "input_cost_per_token": 8.92272e-07, "input_cost_per_token_cache_hit": 4.4e-08, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 384000, "max_tokens": 384000, "mode": "chat", - "output_cost_per_token": 1.862148e-06, + "output_cost_per_token": 1.784544e-06, "source": "https://openrouter.ai/api/v1/models", "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "cache_read_input_token_cost": 7.75895e-08, + "cache_read_input_token_cost": 7.4356e-08, "supports_audio_input": false, "supports_pdf_input": false, "supports_vision": false, @@ -43323,23 +39453,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7e-07, - "input_cost_per_token": 1e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 4e-07, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true - }, "openrouter/google/gemini-2.5-flash": { "cache_creation_input_token_cost": 8.33333333333333e-08, "cache_read_input_audio_token_cost": 1e-07, @@ -43763,27 +39876,6 @@ "supports_vision": true, "supports_web_search": false }, - "openrouter/mistralai/mistral-large-2512": { - "cache_read_input_token_cost": 5.5e-08, - "input_cost_per_image": 0, - "input_cost_per_token": 5.5e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 209715, - "max_tokens": 209715, - "mode": "chat", - "output_cost_per_token": 1.65e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/mistralai/mistral-7b-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "openrouter", @@ -46534,17 +42626,6 @@ "supports_reasoning": true, "supports_system_messages": true }, - "rerank-english-v2.0": { - "input_cost_per_query": 0.002, - "input_cost_per_token": 0.0, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "rerank", - "output_cost_per_token": 0.0, - "deprecation_date": "2025-04-30" - }, "rerank-english-v3.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, @@ -46555,17 +42636,6 @@ "mode": "rerank", "output_cost_per_token": 0.0 }, - "rerank-multilingual-v2.0": { - "input_cost_per_query": 0.002, - "input_cost_per_token": 0.0, - "litellm_provider": "cohere", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "rerank", - "output_cost_per_token": 0.0, - "deprecation_date": "2025-04-30" - }, "rerank-multilingual-v3.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, @@ -46706,31 +42776,6 @@ "output_cost_per_token": 7e-06, "source": "https://cloud.sambanova.ai/plans/pricing" }, - "sambanova/DeepSeek-R1-Distill-Llama-70B": { - "deprecation_date": "2026-03-20", - "input_cost_per_token": 7e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.4e-06, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/DeepSeek-V3-0324": { - "deprecation_date": "2026-04-14", - "input_cost_per_token": 3e-06, - "litellm_provider": "sambanova", - "max_input_tokens": 32768, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 4.5e-06, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "sambanova/Llama-4-Maverick-17B-128E-Instruct": { "input_cost_per_token": 6.3e-07, "litellm_provider": "sambanova", @@ -46748,73 +42793,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "sambanova/Llama-4-Scout-17B-16E-Instruct": { - "deprecation_date": "2025-06-19", - "input_cost_per_token": 4e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "metadata": { - "notes": "For vision models, images are converted to 6432 input tokens and are billed at that amount" - }, - "mode": "chat", - "output_cost_per_token": 7e-07, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "sambanova/Meta-Llama-3.1-405B-Instruct": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 5e-06, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-05, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "sambanova/Meta-Llama-3.1-8B-Instruct": { - "deprecation_date": "2026-04-14", - "input_cost_per_token": 1e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "sambanova/Meta-Llama-3.2-1B-Instruct": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 4e-08, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 8e-08, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/Meta-Llama-3.2-3B-Instruct": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 8e-08, - "litellm_provider": "sambanova", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.6e-07, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, "sambanova/Meta-Llama-3.3-70B-Instruct": { "input_cost_per_token": 6e-07, "litellm_provider": "sambanova", @@ -46828,54 +42806,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "sambanova/Meta-Llama-Guard-3-8B": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 3e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 3e-07, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/QwQ-32B": { - "deprecation_date": "2025-06-25", - "input_cost_per_token": 5e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 16384, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 1e-06, - "source": "https://cloud.sambanova.ai/plans/pricing" - }, - "sambanova/Qwen2-Audio-7B-Instruct": { - "deprecation_date": "2025-06-19", - "input_cost_per_token": 5e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 4096, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 0.0001, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_audio_input": true - }, - "sambanova/Qwen3-32B": { - "deprecation_date": "2026-04-06", - "input_cost_per_token": 4e-07, - "litellm_provider": "sambanova", - "max_input_tokens": 8192, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://cloud.sambanova.ai/plans/pricing", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "sambanova/DeepSeek-V3.1": { "max_tokens": 131072, "max_input_tokens": 131072, @@ -47468,27 +43398,6 @@ "mode": "image_generation", "output_cost_per_image": 0.14 }, - "standard/1024-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 3.81469e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "standard/1024-x-1792/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 4.359e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, - "standard/1792-x-1024/dall-e-3": { - "deprecation_date": "2026-05-12", - "input_cost_per_pixel": 4.359e-08, - "litellm_provider": "openai", - "mode": "image_generation", - "output_cost_per_pixel": 0.0 - }, "linkup/search": { "input_cost_per_query": 0.00587, "litellm_provider": "linkup", @@ -47623,36 +43532,6 @@ "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, - "text-moderation-007": { - "deprecation_date": "2025-10-27", - "input_cost_per_token": 0.0, - "litellm_provider": "openai", - "max_input_tokens": 32768, - "max_output_tokens": 0, - "max_tokens": 0, - "mode": "moderation", - "output_cost_per_token": 0.0 - }, - "text-moderation-latest": { - "deprecation_date": "2025-10-27", - "input_cost_per_token": 0.0, - "litellm_provider": "openai", - "max_input_tokens": 32768, - "max_output_tokens": 0, - "max_tokens": 0, - "mode": "moderation", - "output_cost_per_token": 0.0 - }, - "text-moderation-stable": { - "deprecation_date": "2025-10-27", - "input_cost_per_token": 0.0, - "litellm_provider": "openai", - "max_input_tokens": 32768, - "max_output_tokens": 0, - "max_tokens": 0, - "mode": "moderation", - "output_cost_per_token": 0.0 - }, "text-multilingual-embedding-002": { "deprecation_date": "2027-04-01", "input_cost_per_character": 2.5e-08, @@ -47750,19 +43629,6 @@ "mode": "chat", "output_cost_per_token": 1e-07 }, - "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { - "deprecation_date": "2026-02-06", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "input_cost_per_token": 1.2e-06, - "output_cost_per_token": 1.2e-06, - "max_input_tokens": 131072, - "source": "https://api.together.ai/v1/models" - }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { "litellm_provider": "together_ai", "mode": "chat", @@ -47775,87 +43641,6 @@ "max_input_tokens": 32768, "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { - "deprecation_date": "2026-07-10", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "source": "https://www.together.ai/models/qwen3-235b-a22b-instruct-2507-fp8", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": { - "deprecation_date": "2026-04-16", - "input_cost_per_token": 6.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://www.together.ai/models/qwen3-235b-a22b-thinking-2507", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": { - "deprecation_date": "2026-02-06", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 40000, - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://www.together.ai/models/qwen3-235b-a22b-fp8-tput", - "supports_function_calling": false, - "supports_parallel_function_calling": false, - "supports_tool_choice": false - }, - "together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { - "deprecation_date": "2026-06-04", - "input_cost_per_token": 2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/deepseek-ai/DeepSeek-R1": { - "deprecation_date": "2026-05-14", - "input_cost_per_token": 3e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 128000, - "max_output_tokens": 20480, - "max_tokens": 20480, - "metadata": { - "successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro-0813" - }, - "mode": "chat", - "output_cost_per_token": 7e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/deepseek-ai/DeepSeek-R1-0528-tput": { - "deprecation_date": "2026-02-03", - "input_cost_per_token": 5.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.19e-06, - "source": "https://www.together.ai/models/deepseek-r1-0528-throughput", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/deepseek-ai/DeepSeek-V3": { "input_cost_per_token": 1.25e-06, "litellm_provider": "together_ai", @@ -47872,33 +43657,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/deepseek-ai/DeepSeek-V3.1": { - "deprecation_date": "2026-05-14", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_tokens": 16384, - "metadata": { - "successor": "together_ai/deepseek-ai/DeepSeek-V4-Pro-0813" - }, - "mode": "chat", - "output_cost_per_token": 1.7e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "max_input_tokens": 131072, - "max_output_tokens": 16384 - }, - "together_ai/meta-llama/Llama-3.2-3B-Instruct-Turbo": { - "deprecation_date": "2026-03-06", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": { "input_cost_per_token": 1.04e-06, "litellm_provider": "together_ai", @@ -47912,112 +43670,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 0, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 0, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { - "deprecation_date": "2026-03-31", - "input_cost_per_token": 2.7e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.5e-07, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": { - "deprecation_date": "2026-02-06", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 5.9e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { - "deprecation_date": "2026-02-06", - "input_cost_per_token": 3.5e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 3.5e-06, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { - "deprecation_date": "2026-02-25", - "input_cost_per_token": 8.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.8e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": { - "deprecation_date": "2026-03-06", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/mistralai/Mistral-7B-Instruct-v0.1": { - "deprecation_date": "2025-11-13", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "input_cost_per_token": 2e-07, - "output_cost_per_token": 2e-07, - "max_input_tokens": 32768, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/mistralai/Mistral-Small-24B-Instruct-2501": { - "deprecation_date": "2026-04-02", - "litellm_provider": "together_ai", - "mode": "chat", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 3e-07, - "max_input_tokens": 32768, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": { - "deprecation_date": "2026-04-16", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/moonshotai/Kimi-K2-Instruct": { "input_cost_per_token": 1e-06, "litellm_provider": "together_ai", @@ -48046,19 +43698,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/openai/gpt-oss-20b": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 5e-08, - "litellm_provider": "together_ai", - "max_input_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/togethercomputer/CodeLlama-34b-Instruct": { "litellm_provider": "together_ai", "mode": "chat", @@ -48066,19 +43705,6 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true }, - "together_ai/zai-org/GLM-4.5-Air-FP8": { - "deprecation_date": "2026-04-02", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 1.1e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/zai-org/GLM-4.6": { "input_cost_per_token": 6e-07, "litellm_provider": "together_ai", @@ -48095,102 +43721,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "together_ai/zai-org/GLM-4.7": { - "deprecation_date": "2026-04-02", - "input_cost_per_token": 4.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 202752, - "max_tokens": 202752, - "metadata": { - "successor": "together_ai/zai-org/GLM-5.2" - }, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, - "together_ai/moonshotai/Kimi-K2.5": { - "deprecation_date": "2026-05-21", - "input_cost_per_token": 5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 256000, - "max_tokens": 256000, - "metadata": { - "successor": "together_ai/moonshotai/Kimi-K3" - }, - "mode": "chat", - "output_cost_per_token": 2.8e-06, - "source": "https://www.together.ai/models/kimi-k2-5", - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_reasoning": true - }, - "together_ai/moonshotai/Kimi-K2-Instruct-0905": { - "deprecation_date": "2026-03-06", - "input_cost_per_token": 1e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "metadata": { - "successor": "together_ai/moonshotai/Kimi-K3" - }, - "mode": "chat", - "output_cost_per_token": 3e-06, - "source": "https://www.together.ai/models/kimi-k2-0905", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct": { - "deprecation_date": "2026-04-02", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "metadata": { - "successor": "together_ai/Qwen/Qwen3.7-Plus" - }, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { - "deprecation_date": "2026-02-25", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "metadata": { - "successor": "together_ai/Qwen/Qwen3.6-Plus" - }, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/Qwen/Qwen3.5-397B-A17B": { - "cache_read_input_token_cost": 3.5e-07, - "deprecation_date": "2026-06-29", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 3.6e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/MiniMaxAI/MiniMax-M3": { "cache_read_input_token_cost": 6e-08, "input_cost_per_token": 3e-07, @@ -48312,23 +43842,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/deepseek-ai/DeepSeek-V4-Pro": { - "deprecation_date": "2026-08-27", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.74e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 512000, - "max_tokens": 512000, - "mode": "chat", - "output_cost_per_token": 3.48e-06, - "source": "https://docs.together.ai/docs/serverless-models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "together_ai/deepseek-ai/DeepSeek-V4-Pro-0813": { "cache_read_input_token_cost": 1.3e-07, "deprecation_date": "2026-09-29", @@ -48345,52 +43858,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/google/gemma-3n-E4B-it": { - "deprecation_date": "2026-08-25", - "input_cost_per_token": 6e-08, - "litellm_provider": "together_ai", - "max_input_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 1.2e-07, - "source": "https://docs.together.ai/docs/serverless-models" - }, - "together_ai/google/gemma-4-31B-it": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 3.9e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 9.7e-07, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "together_ai/intfloat/multilingual-e5-large-instruct": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 2e-08, - "litellm_provider": "together_ai", - "max_input_tokens": 514, - "max_tokens": 514, - "mode": "embedding", - "output_cost_per_token": 2e-08, - "output_vector_size": 1024, - "source": "https://docs.together.ai/docs/serverless-models" - }, - "together_ai/meta-llama/Llama-Guard-4-12B": { - "deprecation_date": "2026-08-25", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 1048576, - "max_tokens": 1048576, - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://docs.together.ai/docs/serverless-models" - }, "together_ai/meta-models/Muse-Glimmer-30B": { "cache_read_input_token_cost": 4e-08, "input_cost_per_token": 3.5e-07, @@ -48402,23 +43869,6 @@ "source": "https://api.together.ai/v1/models", "supports_prompt_caching": true }, - "together_ai/moonshotai/Kimi-K2.7-Code": { - "deprecation_date": "2026-08-27", - "cache_read_input_token_cost": 1.9e-07, - "input_cost_per_token": 9.5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 4e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "together_ai/moonshotai/Kimi-K3": { "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, @@ -48441,33 +43891,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "together_ai/nvidia/nemotron-3-ultra-550b-a55b": { - "deprecation_date": "2026-08-27", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 512288, - "max_tokens": 512288, - "mode": "chat", - "output_cost_per_token": 3.6e-06, - "source": "https://api.together.ai/v1/models", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, - "together_ai/pearl-ai/gemma-4-31b-it": { - "deprecation_date": "2026-08-27", - "input_cost_per_token": 2.8e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "max_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 8.6e-07, - "source": "https://docs.together.ai/docs/serverless-models" - }, "together_ai/thinkingmachines/Inkling": { "cache_read_input_token_cost": 1.7e-07, "input_cost_per_token": 1e-06, @@ -48483,18 +43906,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/thinkingmachines/Inkling-Small": { - "deprecation_date": "2026-09-14", - "cache_read_input_token_cost": 1e-07, - "input_cost_per_token": 5e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 524288, - "max_tokens": 524288, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://api.together.ai/v1/models", - "supports_prompt_caching": true - }, "together_ai/zai-org/GLM-5.2": { "cache_read_input_token_cost": 2.6e-07, "input_cost_per_token": 1.4e-06, @@ -48641,22 +44052,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "us.amazon.nova-premier-v1:0": { - "deprecation_date": "2026-09-14", - "input_cost_per_token": 2.5e-06, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 1000000, - "max_output_tokens": 10000, - "max_tokens": 10000, - "mode": "chat", - "output_cost_per_token": 1.25e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_vision": true, - "cache_read_input_token_cost": 6.25e-07 - }, "us.amazon.nova-pro-v1:0": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 8e-07, @@ -48774,23 +44169,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "us.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 2.5e-07, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.25e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 2.5e-08, - "cache_creation_input_token_cost": 3.125e-07 - }, "us.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", @@ -48806,23 +44184,6 @@ "cache_read_input_token_cost": 1.5e-06, "cache_creation_input_token_cost": 1.875e-05 }, - "us.anthropic.claude-3-sonnet-20240229-v1:0": { - "deprecation_date": "2026-07-30", - "input_cost_per_token": 3e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-07, - "cache_creation_input_token_cost": 3.75e-06 - }, "us.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, @@ -48887,23 +44248,6 @@ "input_cost_per_token_batches": 1.65e-06, "output_cost_per_token_batches": 8.25e-06 }, - "us-gov.anthropic.claude-3-haiku-20240307-v1:0": { - "deprecation_date": "2026-09-10", - "input_cost_per_token": 3e-07, - "litellm_provider": "bedrock_converse", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 1.5e-06, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3e-08, - "cache_creation_input_token_cost": 3.75e-07 - }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -50031,34 +45375,6 @@ "output_cost_per_token": 9e-07, "supports_tool_choice": true }, - "vercel_ai_gateway/google/gemini-2.0-flash": { - "deprecation_date": "2026-06-01", - "input_cost_per_token": 1.5e-07, - "litellm_provider": "vercel_ai_gateway", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 6e-07, - "supports_vision": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true - }, - "vercel_ai_gateway/google/gemini-2.0-flash-lite": { - "deprecation_date": "2026-06-01", - "input_cost_per_token": 7.5e-08, - "litellm_provider": "vercel_ai_gateway", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "supports_vision": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_response_schema": true - }, "vercel_ai_gateway/google/gemini-2.5-flash": { "input_cost_per_token": 3e-07, "litellm_provider": "vercel_ai_gateway", @@ -50942,28 +46258,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "vertex_ai/claude-3-7-sonnet@20250219": { - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "deprecation_date": "2026-05-11", - "input_cost_per_token": 3e-06, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true - }, "vertex_ai/claude-3-haiku": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-anthropic_models", @@ -51042,72 +46336,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "vertex_ai/claude-opus-4": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "vertex_ai/claude-opus-4-1": { - "deprecation_date": "2026-08-05", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "input_cost_per_token_batches": 7.5e-06, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "output_cost_per_token_batches": 3.75e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "vertex_ai/claude-opus-4-1@20250805": { - "deprecation_date": "2026-08-05", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "input_cost_per_token_batches": 7.5e-06, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "output_cost_per_token_batches": 3.75e-05, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_tool_choice": true, - "supports_vision": true - }, "vertex_ai/claude-opus-4-5": { "deprecation_date": "2026-11-24", "cache_creation_input_token_cost": 6.25e-06, @@ -51708,98 +46936,6 @@ "supports_native_streaming": true, "prompt_cache_min_tokens": 1024 }, - "vertex_ai/claude-opus-4@20250514": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 1.875e-05, - "cache_creation_input_token_cost_above_1hr": 3e-05, - "cache_read_input_token_cost": 1.5e-06, - "input_cost_per_token": 1.5e-05, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 32000, - "max_tokens": 32000, - "mode": "chat", - "output_cost_per_token": 7.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "vertex_ai/claude-sonnet-4": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, - "vertex_ai/claude-sonnet-4@20250514": { - "deprecation_date": "2026-05-14", - "cache_creation_input_token_cost": 3.75e-06, - "cache_creation_input_token_cost_above_1hr": 6e-06, - "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, - "input_cost_per_token_above_200k_tokens": 6e-06, - "output_cost_per_token_above_200k_tokens": 2.25e-05, - "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, - "cache_read_input_token_cost_above_200k_tokens": 6e-07, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.5e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "prompt_cache_min_tokens": 1024 - }, "vertex_ai/mistralai/codestral-2@001": { "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-mistral_models", @@ -51988,6 +47124,7 @@ "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "cache_read_input_token_cost_batches": 1e-07, "cache_read_input_token_cost_flex": 1e-07, "cache_read_input_token_cost_priority": 3.6e-07, "deprecation_date": "2027-05-28", @@ -52012,7 +47149,8 @@ "output_cost_per_token_flex": 6e-06, "output_cost_per_token_priority": 2.16e-05, "supports_reasoning": false, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "cache_read_input_token_cost_batches": 1e-07 }, "vertex_ai/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -52032,6 +47170,7 @@ }, "vertex_ai/gemini-3.1-flash-image": { "cache_read_input_token_cost": 5e-08, + "cache_read_input_token_cost_batches": 2.5e-08, "cache_read_input_token_cost_flex": 2.5e-08, "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, @@ -52049,7 +47188,8 @@ "output_cost_per_token_batches": 1.5e-06, "output_cost_per_token_flex": 1.5e-06, "supports_reasoning": false, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "cache_read_input_token_cost_batches": 2.5e-08 }, "vertex_ai/gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -52067,6 +47207,7 @@ }, "vertex_ai/gemini-3.1-flash-lite-image": { "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "input_cost_per_image": 0.00028, "input_cost_per_token": 2.5e-07, @@ -52104,7 +47245,8 @@ "supports_response_schema": false, "supports_system_messages": true, "supports_video_input": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost_batches": 1.25e-08 }, "vertex_ai/gemini-3.1-flash-lite-preview": { "cache_read_input_token_cost": 2.5e-08, @@ -52159,6 +47301,7 @@ "cache_read_input_audio_token_cost": 5e-08, "deprecation_date": "2027-05-07", "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_batches": 1.25e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_audio_token": 5e-07, @@ -52214,11 +47357,13 @@ }, "web_search_billing_unit": "per_query", "google_maps_grounding_cost_per_query": 0.014, - "input_cost_per_audio_token_batches": 2.5e-07 + "input_cost_per_audio_token_batches": 2.5e-07, + "cache_read_input_token_cost_batches": 1.25e-08 }, "vertex_ai/gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", "cache_read_input_token_cost": 3e-08, + "cache_read_input_token_cost_batches": 1.5e-08, "cache_read_input_token_cost_flex": 1.5e-08, "cache_read_input_token_cost_priority": 5.4e-08, "input_cost_per_token": 3e-07, @@ -52272,10 +47417,12 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", - "google_maps_grounding_cost_per_query": 0.014 + "google_maps_grounding_cost_per_query": 0.014, + "cache_read_input_token_cost_batches": 1.5e-08 }, "vertex_ai/deep-research-pro-preview-12-2025": { "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_batches": 1e-07, "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, @@ -52288,63 +47435,8 @@ "output_cost_per_image_token": 0.00012, "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 6e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" - }, - "vertex_ai/imagegeneration@006": { - "deprecation_date": "2025-09-24", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-fast-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-generate-002": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-3.0-capability-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/image/edit-insert-objects" - }, - "vertex_ai/imagen-4.0-fast-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.02, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-4.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.04, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" - }, - "vertex_ai/imagen-4.0-ultra-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-image-models", - "mode": "image_generation", - "output_cost_per_image": 0.06, - "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", + "cache_read_input_token_cost_batches": 1e-07 }, "vertex_ai/jamba-1.5": { "input_cost_per_token": 2e-07, @@ -53090,51 +48182,6 @@ "supports_function_calling": true, "supports_tool_choice": true }, - "vertex_ai/veo-2.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-video-models", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.35, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, - "vertex_ai/veo-3.0-fast-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-video-models", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.15, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, - "vertex_ai/veo-3.0-generate-001": { - "deprecation_date": "2026-06-30", - "litellm_provider": "vertex_ai-video-models", - "max_input_tokens": 1024, - "max_tokens": 1024, - "mode": "video_generation", - "output_cost_per_second": 0.4, - "source": "https://ai.google.dev/gemini-api/docs/video", - "supported_modalities": [ - "text" - ], - "supported_output_modalities": [ - "video" - ] - }, "vertex_ai/veo-3.1-generate-preview": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, @@ -53423,59 +48470,6 @@ "mode": "chat", "source": "https://wandb.ai/site/pricing/tokens/" }, - "wandb/zai-org/GLM-4.5": { - "deprecation_date": "2026-03-04", - "supports_reasoning": true, - "max_tokens": 131072, - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "input_cost_per_token": 0.055, - "output_cost_per_token": 0.2, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": { - "deprecation_date": "2026-08-04", - "max_tokens": 262144, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 1e-07, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": { - "deprecation_date": "2026-08-25", - "max_tokens": 262144, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "input_cost_per_token": 1e-06, - "output_cost_per_token": 1.5e-06, - "litellm_provider": "wandb", - "mode": "chat", - "source": "https://wandb.ai/site/pricing/tokens/" - }, - "wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": { - "deprecation_date": "2026-08-04", - "supports_reasoning": true, - "max_tokens": 262144, - "max_input_tokens": 262144, - "max_output_tokens": 262144, - "input_cost_per_token": 1e-07, - "output_cost_per_token": 1e-07, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/moonshotai/Kimi-K2-Instruct": { - "deprecation_date": "2026-03-04", - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 6e-07, - "output_cost_per_token": 2.5e-06, - "litellm_provider": "wandb", - "mode": "chat" - }, "wandb/moonshotai/Kimi-K2.5": { "max_tokens": 262144, "max_input_tokens": 262144, @@ -53491,20 +48485,6 @@ "supports_response_schema": true, "supports_vision": true }, - "wandb/MiniMaxAI/MiniMax-M2.5": { - "deprecation_date": "2026-08-25", - "max_tokens": 197000, - "max_input_tokens": 197000, - "max_output_tokens": 197000, - "input_cost_per_token": 3e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "wandb", - "mode": "chat", - "source": "https://wandb.ai/inference/coreweave/cw_MiniMaxAI_MiniMax-M2.5", - "supports_function_calling": true, - "supports_reasoning": true, - "supports_response_schema": true - }, "wandb/meta-llama/Llama-3.1-8B-Instruct": { "max_tokens": 128000, "max_input_tokens": 131000, @@ -53526,27 +48506,6 @@ "mode": "chat", "source": "https://wandb.ai/site/pricing/tokens/" }, - "wandb/deepseek-ai/DeepSeek-R1-0528": { - "deprecation_date": "2026-03-04", - "supports_reasoning": true, - "max_tokens": 161000, - "max_input_tokens": 161000, - "max_output_tokens": 161000, - "input_cost_per_token": 1.35e-06, - "output_cost_per_token": 5.4e-06, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/deepseek-ai/DeepSeek-V3-0324": { - "deprecation_date": "2026-03-04", - "max_tokens": 161000, - "max_input_tokens": 161000, - "max_output_tokens": 161000, - "input_cost_per_token": 1.14e-06, - "output_cost_per_token": 2.75e-06, - "litellm_provider": "wandb", - "mode": "chat" - }, "wandb/meta-llama/Llama-3.3-70B-Instruct": { "max_tokens": 128000, "max_input_tokens": 128000, @@ -53557,26 +48516,6 @@ "mode": "chat", "source": "https://wandb.ai/site/pricing/tokens/" }, - "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": { - "deprecation_date": "2026-04-21", - "max_tokens": 64000, - "max_input_tokens": 64000, - "max_output_tokens": 64000, - "input_cost_per_token": 1.7e-07, - "output_cost_per_token": 6.6e-07, - "litellm_provider": "wandb", - "mode": "chat" - }, - "wandb/microsoft/Phi-4-mini-instruct": { - "deprecation_date": "2026-08-04", - "max_tokens": 128000, - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "input_cost_per_token": 0.008, - "output_cost_per_token": 0.035, - "litellm_provider": "wandb", - "mode": "chat" - }, "watsonx/ibm/granite-3-8b-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "watsonx", @@ -53977,440 +48916,6 @@ "deprecation_date": "2027-02-26", "source": "https://developers.openai.com/api/docs/pricing" }, - "xai/grok-3": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-beta": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-fast-beta": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-fast-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini": { - "cache_read_input_token_cost": 2e-07, - "deprecation_date": "2026-02-28", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-beta": { - "cache_read_input_token_cost": 2e-07, - "deprecation_date": "2026-02-28", - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-fast": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-fast-beta": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-fast-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-3-mini-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://x.ai/api#pricing", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-02-28", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4": { - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-fast-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-fast-non-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-0709": { - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-latest": { - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_tool_choice": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-reasoning-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-non-reasoning": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-non-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, - "xai/grok-4-1-fast-non-reasoning-latest": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1.25e-06, - "litellm_provider": "xai", - "max_input_tokens": 2000000.0, - "max_output_tokens": 2000000.0, - "max_tokens": 2000000.0, - "mode": "chat", - "output_cost_per_token": 2.5e-06, - "source": "https://docs.x.ai/docs/models/grok-4-1-fast-non-reasoning", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "deprecation_date": "2026-05-15", - "input_cost_per_token_above_200k_tokens": 2.5e-06, - "output_cost_per_token_above_200k_tokens": 5e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07 - }, "xai/grok-4.20-multi-agent-beta-0309": { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 1.25e-06, @@ -54655,72 +49160,6 @@ "supports_vision": true, "supports_web_search": true }, - "xai/grok-code-fast": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.x.ai/v1/language-models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "input_cost_per_token_above_200k_tokens": 2e-06, - "output_cost_per_token_above_200k_tokens": 4e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "supports_response_schema": true, - "supports_vision": true, - "deprecation_date": "2026-05-15", - "input_cost_per_image_token": 1e-06 - }, - "xai/grok-code-fast-1": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.x.ai/v1/language-models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "input_cost_per_token_above_200k_tokens": 2e-06, - "output_cost_per_token_above_200k_tokens": 4e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "supports_response_schema": true, - "supports_vision": true, - "deprecation_date": "2026-05-15", - "input_cost_per_image_token": 1e-06 - }, - "xai/grok-code-fast-1-0825": { - "cache_read_input_token_cost": 2e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "xai", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.x.ai/v1/language-models", - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true, - "input_cost_per_token_above_200k_tokens": 2e-06, - "output_cost_per_token_above_200k_tokens": 4e-06, - "cache_read_input_token_cost_above_200k_tokens": 4e-07, - "supports_response_schema": true, - "supports_vision": true, - "deprecation_date": "2026-05-15", - "input_cost_per_image_token": 1e-06 - }, "zai.glm-4.7": { "input_cost_per_token": 6e-07, "litellm_provider": "bedrock_converse", @@ -57463,30 +51902,6 @@ "supports_reasoning": true, "supports_vision": true }, - "scaleway/google/gemma-3-27b-it": { - "input_cost_per_token": 2.5e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 40000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 5e-07, - "supports_function_calling": true, - "supports_vision": true, - "deprecation_date": "2026-08-01" - }, - "scaleway/hcompany/holo2-30b-a3b": { - "input_cost_per_token": 3e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 22000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 7e-07, - "supports_reasoning": true, - "supports_vision": true, - "deprecation_date": "2026-08-09" - }, "scaleway/mistralai/mistral-medium-3.5-128b": { "input_cost_per_token": 1.5e-06, "litellm_provider": "scaleway", @@ -57500,29 +51915,6 @@ "supports_vision": true, "supports_tool_choice": true }, - "scaleway/mistralai/devstral-2-123b-instruct-2512": { - "input_cost_per_token": 4e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 200000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 2e-06, - "supports_function_calling": true, - "deprecation_date": "2026-08-01" - }, - "scaleway/mistralai/voxtral-small-24b-2507": { - "input_cost_per_audio_token": 1.5e-07, - "input_cost_per_token": 1.5e-07, - "litellm_provider": "scaleway", - "max_input_tokens": 32000, - "max_output_tokens": 16384, - "max_tokens": 16384, - "mode": "chat", - "output_cost_per_token": 3.5e-07, - "supports_audio_input": true, - "deprecation_date": "2026-08-01" - }, "scaleway/mistralai/mistral-small-3.2-24b-instruct-2506": { "input_cost_per_token": 1.5e-07, "litellm_provider": "scaleway", @@ -59082,26 +53474,6 @@ "/v1/audio/speech" ] }, - "gpt-4o-mini-tts-2025-03-20": { - "deprecation_date": "2026-07-23", - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "mode": "audio_speech", - "output_cost_per_audio_token": 1.2e-05, - "output_cost_per_second": 0.00025, - "output_cost_per_token": 1e-05, - "source": "https://developers.openai.com/api/docs/pricing", - "supported_endpoints": [ - "/v1/audio/speech" - ], - "supported_modalities": [ - "text", - "audio" - ], - "supported_output_modalities": [ - "audio" - ] - }, "gpt-4o-mini-tts-2025-12-15": { "input_cost_per_token": 6e-07, "litellm_provider": "openai", @@ -59205,41 +53577,6 @@ "supports_none_reasoning_effort": false, "supports_xhigh_reasoning_effort": false }, - "gpt-realtime-mini-2025-10-06": { - "cache_creation_input_audio_token_cost": 3e-07, - "cache_read_input_audio_token_cost": 3e-07, - "cache_read_input_token_cost": 6e-08, - "deprecation_date": "2026-07-23", - "input_cost_per_audio_token": 1e-05, - "input_cost_per_image_token": 8e-07, - "input_cost_per_token": 6e-07, - "litellm_provider": "openai", - "max_input_tokens": 128000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "realtime", - "output_cost_per_audio_token": 2e-05, - "output_cost_per_token": 2.4e-06, - "source": "https://developers.openai.com/api/docs/pricing", - "supported_endpoints": [ - "/v1/realtime" - ], - "supported_modalities": [ - "text", - "image", - "audio" - ], - "supported_output_modalities": [ - "text", - "audio" - ], - "supports_audio_input": true, - "supports_audio_output": true, - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_system_messages": true, - "supports_tool_choice": true - }, "gpt-realtime-mini-2025-12-15": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, @@ -59394,42 +53731,6 @@ "tpm": 250000, "rpm": 10 }, - "gemini/gemini-2.0-flash-lite-001": { - "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-06-01", - "input_cost_per_audio_token": 7.5e-08, - "input_cost_per_token": 7.5e-08, - "litellm_provider": "gemini", - "max_input_tokens": 1048576, - "max_output_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 3e-07, - "rpm": 4000, - "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.0-flash-lite", - "supported_modalities": [ - "text", - "image", - "audio", - "video" - ], - "supported_output_modalities": [ - "text" - ], - "supports_audio_output": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tpm": 4000000, - "search_context_cost_per_query": { - "search_context_size_low": 0.035, - "search_context_size_medium": 0.035, - "search_context_size_high": 0.035 - } - }, "gemini-2.5-flash-native-audio-latest": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 5e-07, @@ -65589,23 +59890,6 @@ "image" ] }, - "xai/grok-imagine-image-pro": { - "input_cost_per_image": 0.05, - "litellm_provider": "xai", - "mode": "image_generation", - "source": "https://docs.x.ai/docs/models", - "supported_endpoints": [ - "/v1/images/generations" - ], - "supported_modalities": [ - "text", - "image" - ], - "supported_output_modalities": [ - "image" - ], - "deprecation_date": "2026-05-15" - }, "xai/grok-imagine-image-2.0": { "input_cost_per_image": 0.06, "litellm_provider": "xai", @@ -66223,16 +60507,6 @@ "output_cost_per_token": 2.82e-07, "source": "https://api.together.ai/v1/models" }, - "together_ai/moonshotai/Kimi-K2.6": { - "deprecation_date": "2026-08-19", - "input_cost_per_token": 1.2e-06, - "output_cost_per_token": 4.5e-06, - "cache_read_input_token_cost": 2e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "together_ai/moonshotai/Kimi-K2.5-fp4": { "input_cost_per_token": 5e-07, "output_cost_per_token": 2.8e-06, @@ -66250,25 +60524,6 @@ "mode": "chat", "source": "https://api.together.ai/v1/models" }, - "together_ai/zai-org/GLM-5": { - "deprecation_date": "2026-06-22", - "input_cost_per_token": 1e-06, - "output_cost_per_token": 3.2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 202752, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, - "together_ai/zai-org/GLM-5.1": { - "deprecation_date": "2026-07-10", - "input_cost_per_token": 1.4e-06, - "output_cost_per_token": 4.4e-06, - "cache_read_input_token_cost": 2.6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 202752, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "together_ai/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, "output_cost_per_token": 7e-06, @@ -66277,33 +60532,6 @@ "mode": "chat", "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen3-Coder-Next-FP8": { - "deprecation_date": "2026-05-14", - "input_cost_per_token": 5e-07, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen3-VL-32B-Instruct": { - "deprecation_date": "2026-02-25", - "input_cost_per_token": 5e-07, - "output_cost_per_token": 1.5e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen3-VL-8B-Instruct": { - "deprecation_date": "2026-04-16", - "input_cost_per_token": 1.8e-07, - "output_cost_per_token": 6.8e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "together_ai/mistralai/Ministral-3-14B-Instruct-2512": { "input_cost_per_token": 2e-07, "output_cost_per_token": 2e-07, @@ -66328,15 +60556,6 @@ "mode": "chat", "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/QwQ-32B": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 1.2e-06, - "output_cost_per_token": 1.2e-06, - "litellm_provider": "together_ai", - "max_input_tokens": 131072, - "mode": "chat", - "source": "https://api.together.ai/v1/models" - }, "cerebras/gemma-4-31b": { "input_cost_per_token": 9.9e-07, "litellm_provider": "cerebras", @@ -68316,7 +62535,7 @@ "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 4.4e-06, - "source": "https://www.baseten.co/pricing/", + "source": "https://inference.baseten.co/v1/models", "supported_modalities": [ "text", "image" @@ -68326,6 +62545,7 @@ ], "supports_function_calling": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true @@ -69111,13 +63331,13 @@ "supports_web_search": false }, "openrouter/qwen/qwen3.6-27b": { - "input_cost_per_token": 3e-07, - "output_cost_per_token": 2e-06, - "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 3.2e-07, + "output_cost_per_token": 2.7e-06, + "cache_read_input_token_cost": 1.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_output_tokens": 262140, + "max_tokens": 262140, "mode": "chat", "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, @@ -71078,38 +65298,6 @@ "output_cost_per_token": 1.5e-07, "source": "https://api.together.ai/v1/models" }, - "together_ai/deepseek-ai/deepseek-coder-33b-instruct": { - "deprecation_date": "2024-08-22", - "input_cost_per_token": 8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { - "deprecation_date": "2025-12-23", - "input_cost_per_token": 2e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 2e-06, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 1.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 1.6e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.6e-06, - "source": "https://api.together.ai/v1/models" - }, "vertex_ai/gemini-2.5-flash-native-audio": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 5e-07, @@ -71128,16 +65316,6 @@ "output_cost_per_token": 1e-05, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, - "vertex_ai/gemini-3.1-flash-live-preview": { - "input_cost_per_audio_token": 3e-06, - "input_cost_per_second": 8.33333333333e-05, - "input_cost_per_token": 7.5e-07, - "litellm_provider": "vertex_ai", - "mode": "realtime", - "output_cost_per_audio_token": 1.2e-05, - "output_cost_per_token": 4.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" - }, "vertex_ai/gemini-3.1-flash-tts-preview": { "input_cost_per_token": 1e-06, "input_cost_per_token_batches": 5e-07, @@ -71170,16 +65348,6 @@ "output_cost_per_token": 9e-06, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, - "vertex_ai/gemini-robotics-er-2": { - "cache_read_input_token_cost": 1e-07, - "input_cost_per_token": 1e-06, - "input_cost_per_token_batches": 5e-07, - "litellm_provider": "vertex_ai", - "mode": "chat", - "output_cost_per_token": 5e-06, - "output_cost_per_token_batches": 2.5e-06, - "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" - }, "vertex_ai/gemma-4-26b-a4b-it": { "cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 1.5e-07, @@ -71188,14 +65356,6 @@ "output_cost_per_token": 6e-07, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing" }, - "together_ai/google/gemma-2-27b-it": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://api.together.ai/v1/models" - }, "gpt-5.5-cyber": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 1.25e-05, @@ -71213,14 +65373,6 @@ "output_cost_per_token": 2.5e-05, "source": "https://developers.openai.com/api/docs/pricing" }, - "together_ai/meta-llama/Llama-3-8b-chat-hf": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://api.together.ai/v1/models" - }, "together_ai/meta-llama/Llama-3.1-405B-Instruct": { "input_cost_per_token": 3.5e-06, "litellm_provider": "together_ai", @@ -71242,38 +65394,6 @@ "output_cost_per_token": 6e-08, "source": "https://api.together.ai/v1/models" }, - "together_ai/meta-llama/Meta-Llama-3-70B-Instruct-Turbo": { - "deprecation_date": "2025-12-23", - "input_cost_per_token": 8.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/meta-llama/Meta-Llama-3-8B-Instruct": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 2e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 2e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 6e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/nvidia/Llama-3.1-Nemotron-70B-Instruct-HF": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 8.8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8.8e-07, - "source": "https://api.together.ai/v1/models" - }, "together_ai/Qwen/Qwen2-1.5B-Instruct": { "input_cost_per_token": 2e-08, "litellm_provider": "together_ai", @@ -71281,22 +65401,6 @@ "output_cost_per_token": 2e-08, "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen2-72B-Instruct": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 9e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 9e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen2-VL-72B-Instruct": { - "deprecation_date": "2025-08-28", - "input_cost_per_token": 1.2e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://api.together.ai/v1/models" - }, "together_ai/Qwen/Qwen2.5-14B-Instruct": { "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", @@ -71311,22 +65415,6 @@ "output_cost_per_token": 1.2e-06, "source": "https://api.together.ai/v1/models" }, - "together_ai/Qwen/Qwen2.5-Coder-32B-Instruct": { - "deprecation_date": "2025-11-13", - "input_cost_per_token": 8e-07, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-07, - "source": "https://api.together.ai/v1/models" - }, - "together_ai/Qwen/Qwen2.5-VL-72B-Instruct": { - "deprecation_date": "2026-01-05", - "input_cost_per_token": 1.95e-06, - "litellm_provider": "together_ai", - "mode": "chat", - "output_cost_per_token": 8e-06, - "source": "https://api.together.ai/v1/models" - }, "azure/eu/codex-mini": { "deprecation_date": "2026-11-15", "cache_read_input_token_cost": 4.13e-07, @@ -71477,15 +65565,6 @@ "output_cost_per_token_priority": 3.08e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/gpt-5.2-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/gpt-5.2-codex": { "deprecation_date": "2027-07-13", "cache_read_input_token_cost": 1.925e-07, @@ -71504,15 +65583,6 @@ "output_cost_per_token_batches": 9.24e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/gpt-5.3-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/gpt-5.3-codex": { "deprecation_date": "2027-08-24", "cache_read_input_token_cost": 1.925e-07, @@ -71589,14 +65659,6 @@ "output_cost_per_token_batches": 2.42e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/eu/o1-preview": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/eu/o3-2025-04-16": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 5.5e-07, @@ -71839,15 +65901,6 @@ "output_cost_per_token_priority": 3.08e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/gpt-5.2-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/gpt-5.2-codex": { "deprecation_date": "2027-07-13", "cache_read_input_token_cost": 1.925e-07, @@ -71866,15 +65919,6 @@ "output_cost_per_token_batches": 9.24e-05, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/gpt-5.3-chat": { - "deprecation_date": "2026-06-29", - "cache_read_input_token_cost": 1.925e-07, - "input_cost_per_token": 1.925e-06, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 1.54e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/gpt-5.3-codex": { "deprecation_date": "2027-08-24", "cache_read_input_token_cost": 1.925e-07, @@ -71938,14 +65982,6 @@ "output_cost_per_token_batches": 2.42e-06, "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" }, - "azure/us/o1-preview": { - "cache_read_input_token_cost": 8.25e-06, - "input_cost_per_token": 1.65e-05, - "litellm_provider": "azure", - "mode": "chat", - "output_cost_per_token": 6.6e-05, - "source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'" - }, "azure/us/o3-deep-research": { "deprecation_date": "2026-11-19", "cache_read_input_token_cost": 2.75e-06, @@ -73191,16 +67227,16 @@ "supports_web_search": true }, "openrouter/~anthropic/claude-opus-latest": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_1hr": 1e-05, - "cache_read_input_token_cost": 5e-07, - "input_cost_per_token": 5e-06, + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, "litellm_provider": "openrouter", "max_input_tokens": 1000000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 2.5e-05, + "output_cost_per_token": 2e-05, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73235,15 +67271,14 @@ "supports_web_search": true }, "openrouter/~deepseek/deepseek-flash-latest": { - "cache_read_input_token_cost": 6e-09, - "input_cost_per_token": 3e-07, + "cache_read_input_token_cost": 3.6e-09, + "input_cost_per_token": 1.2e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 943718, + "max_tokens": 943718, "mode": "chat", - "off_peak_pricing": {"windows":[{"weekdays":["saturday","sunday"],"hours_utc":"00:00-00:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"00:00-01:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"04:00-06:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"10:00-00:00"}],"input_cost_per_token":1.5e-7,"output_cost_per_token":6e-7,"cache_read_input_token_cost":3e-9}, - "output_cost_per_token": 1.2e-06, + "output_cost_per_token": 4.8e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73256,15 +67291,14 @@ "supports_web_search": false }, "openrouter/~deepseek/deepseek-pro-latest": { - "cache_read_input_token_cost": 4.4e-08, - "input_cost_per_token": 1.32e-06, + "cache_read_input_token_cost": 1.2726e-08, + "input_cost_per_token": 3.9996e-07, "litellm_provider": "openrouter", "max_input_tokens": 1048576, - "max_output_tokens": 384000, - "max_tokens": 384000, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "off_peak_pricing": {"windows":[{"weekdays":["saturday","sunday"],"hours_utc":"00:00-00:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"00:00-01:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"04:00-06:00"},{"weekdays":["monday","tuesday","wednesday","thursday","friday"],"hours_utc":"10:00-00:00"}],"input_cost_per_token":6.6e-7,"output_cost_per_token":0.00000198,"cache_read_input_token_cost":2.2e-8}, - "output_cost_per_token": 3.96e-06, + "output_cost_per_token": 1.19988e-06, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73277,14 +67311,14 @@ "supports_web_search": false }, "openrouter/~deepseek/deepseek-v4-flash-latest": { - "cache_read_input_token_cost": 1.6e-08, - "input_cost_per_token": 4e-08, + "cache_read_input_token_cost": 8e-09, + "input_cost_per_token": 3e-08, "litellm_provider": "openrouter", "max_input_tokens": 1310720, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "output_cost_per_token": 6.4e-07, + "output_cost_per_token": 8e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73347,13 +67381,13 @@ }, "openrouter/~moonshotai/kimi-latest": { "cache_read_input_token_cost": 3e-07, - "input_cost_per_token": 3e-06, + "input_cost_per_token": 1.4989e-06, "litellm_provider": "openrouter", "max_input_tokens": 1048576, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "output_cost_per_token": 1.5e-05, + "output_cost_per_token": 1.0758e-05, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73391,19 +67425,19 @@ "supports_web_search": true }, "openrouter/~openai/gpt-luna-latest": { - "cache_creation_input_token_cost": 2.5e-07, - "cache_creation_input_token_cost_above_272k_tokens": 5e-07, - "cache_read_input_token_cost": 2e-08, - "cache_read_input_token_cost_above_272k_tokens": 4e-08, - "input_cost_per_token": 2e-07, - "input_cost_per_token_above_272k_tokens": 4e-07, + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, "litellm_provider": "openrouter", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 1.2e-06, - "output_cost_per_token_above_272k_tokens": 1.8e-06, + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -73509,14 +67543,14 @@ "supports_web_search": true }, "openrouter/~z-ai/glm-flash-latest": { - "cache_read_input_token_cost": 5e-08, - "input_cost_per_token": 1.5e-07, + "cache_read_input_token_cost": 1.5e-08, + "input_cost_per_token": 7.5e-08, "litellm_provider": "openrouter", "max_input_tokens": 1310720, "max_output_tokens": 943718, "max_tokens": 943718, "mode": "chat", - "output_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, @@ -74291,85 +68325,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/deepseek/deepseek-v4-flash-0731:batch": { - "cache_read_input_token_cost": 3.5e-09, - "input_cost_per_token": 1.1e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 3.3e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, - "openrouter/deepseek/deepseek-v4-flash-0731:free": { - "input_cost_per_token": 0.0, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 393216, - "max_tokens": 393216, - "mode": "chat", - "output_cost_per_token": 0.0, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": false, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, - "openrouter/deepseek/deepseek-v4-flash-vision-exp:batch": { - "cache_read_input_token_cost": 3.5e-09, - "input_cost_per_token": 1.1e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 3.3e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, - "openrouter/deepseek/deepseek-v4-pro-0813:batch": { - "cache_read_input_token_cost": 2.2e-08, - "input_cost_per_token": 6.6e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 1.98e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/dots-studio/dots-3-note-preview:free": { "deprecation_date": "2026-12-31", "input_cost_per_token": 0.0, @@ -74885,26 +68840,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/kwaipilot/kat-coder-pro-v2": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 3e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 144000, - "max_tokens": 144000, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": false, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/kwaipilot/kat-coder-pro-v2.5": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 7.4e-07, @@ -74984,26 +68919,6 @@ "supports_vision": true, "supports_web_search": false }, - "openrouter/meta/muse-glimmer-30b:batch": { - "cache_read_input_token_cost": 2e-08, - "input_cost_per_token": 1.75e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 131072, - "max_output_tokens": 117964, - "max_tokens": 117964, - "mode": "chat", - "output_cost_per_token": 7.5e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/meta/muse-spark-1.1": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.25e-06, @@ -75142,26 +69057,6 @@ "supports_vision": false, "supports_web_search": false }, - "openrouter/minimax/minimax-m3:batch": { - "cache_read_input_token_cost": 6e-08, - "input_cost_per_token": 3e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 524288, - "max_output_tokens": 471859, - "max_tokens": 471859, - "mode": "chat", - "output_cost_per_token": 1.2e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/mistralai/codestral-2508:batch": { "cache_read_input_token_cost": 1.5e-08, "input_cost_per_token": 1.5e-07, @@ -76111,24 +70006,105 @@ "supports_vision": true, "supports_web_search": true }, - "openrouter/openai/gpt-oss-120b:batch": { - "input_cost_per_token": 1.5e-07, + "openrouter/openai/gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, "litellm_provider": "openrouter", - "max_input_tokens": 131072, - "max_output_tokens": 117964, - "max_tokens": 117964, + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 6e-07, + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, "source": "https://openrouter.ai/api/v1/models", "supports_audio_input": false, "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-6-luna-pro": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_272k_tokens": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_272k_tokens": 2e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_272k_tokens": 7.5e-07, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "openrouter/openai/gpt-6-sol-pro": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_272k_tokens": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_above_272k_tokens": 1.5e-05, + "source": "https://openrouter.ai/api/v1/models", + "supports_audio_input": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true }, "openrouter/openai/o3-mini:batch": { "cache_read_input_token_cost": 2.75e-07, @@ -76304,45 +70280,6 @@ "supports_vision": true, "supports_web_search": true }, - "openrouter/qwen/qwen3.5-9b:batch": { - "input_cost_per_token": 1.7e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 262144, - "max_output_tokens": 235929, - "max_tokens": 235929, - "mode": "chat", - "output_cost_per_token": 2.5e-07, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": false, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, - "openrouter/qwen/qwen3.8-2.4t-a95b:batch": { - "cache_read_input_token_cost": 2.5e-07, - "input_cost_per_token": 2e-06, - "litellm_provider": "openrouter", - "max_input_tokens": 1010000, - "max_output_tokens": 909000, - "max_tokens": 909000, - "mode": "chat", - "output_cost_per_token": 6e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/qwen/qwen3.8-27b:free": { "input_cost_per_token": 0.0, "litellm_provider": "openrouter", @@ -76875,26 +70812,6 @@ "supports_vision": true, "supports_web_search": false }, - "openrouter/thinkingmachines/inkling:batch": { - "cache_read_input_token_cost": 1.7e-07, - "input_cost_per_token": 1e-06, - "litellm_provider": "openrouter", - "max_input_tokens": 524288, - "max_output_tokens": 471859, - "max_tokens": 471859, - "mode": "chat", - "output_cost_per_token": 4.05e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": true, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": false, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": false - }, "openrouter/thinkingmachines/inkling:free": { "input_cost_per_token": 0.0, "litellm_provider": "openrouter", @@ -77016,26 +70933,6 @@ "supports_vision": true, "supports_web_search": true }, - "openrouter/z-ai/glm-5.2:batch": { - "cache_read_input_token_cost": 7e-08, - "input_cost_per_token": 7e-07, - "litellm_provider": "openrouter", - "max_input_tokens": 1048576, - "max_output_tokens": 943718, - "max_tokens": 943718, - "mode": "chat", - "output_cost_per_token": 2.2e-06, - "source": "https://openrouter.ai/api/v1/models", - "supports_audio_input": false, - "supports_function_calling": true, - "supports_pdf_input": false, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": false, - "supports_web_search": false - }, "openrouter/z-ai/glm-5.3-flash:batch": { "cache_read_input_token_cost": 1.2e-08, "input_cost_per_token": 6e-08, @@ -77765,5 +71662,226 @@ "supports_tool_choice": true, "supports_vision": true, "supports_web_search": false + }, + "baseten/deepseek-ai/DeepSeek-V4.1-Flash": { + "cache_read_input_token_cost": 7e-09, + "input_cost_per_token": 3e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/moonshotai/Kimi-K2.6": { + "cache_read_input_token_cost": 1.6e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 262000, + "max_output_tokens": 262000, + "max_tokens": 262000, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/moonshotai/Kimi-K2.7-Code": { + "cache_read_input_token_cost": 1.6e-07, + "input_cost_per_token": 9.5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 262000, + "max_output_tokens": 262000, + "max_tokens": 262000, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/moonshotai/Kimi-K3": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "max_input_tokens": 202800, + "max_output_tokens": 202800, + "max_tokens": 202800, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/thinkingmachines/inkling": { + "cache_read_input_token_cost": 1.7e-07, + "input_cost_per_token": 1e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4.05e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/thinkingmachines/inkling-small": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/zai-org/GLM-5.2": { + "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/zai-org/GLM-5.3-Flash": { + "cache_read_input_token_cost": 3e-08, + "input_cost_per_token": 1.5e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 5e-07, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "baseten/deepseek-ai/DeepSeek-V4-Flash-0731": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.3e-07, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 2.6e-07, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/deepseek-ai/DeepSeek-V4-Pro": { + "cache_read_input_token_cost": 1.45e-07, + "input_cost_per_token": 1.74e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.48e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/deepseek-ai/DeepSeek-V4-Pro-0813": { + "cache_read_input_token_cost": 1.32e-07, + "input_cost_per_token": 1.32e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.96e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "baseten/zai-org/GLM-5.2-Fast": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "baseten", + "max_input_tokens": 1048576, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://inference.baseten.co/v1/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true } } diff --git a/terraform/litellm/aws/locals.tf b/terraform/litellm/aws/locals.tf index 778d31642c1..b89bd486d02 100644 --- a/terraform/litellm/aws/locals.tf +++ b/terraform/litellm/aws/locals.tf @@ -74,7 +74,7 @@ locals { "/v1/models*", "/models*", "/openai/*", "/engines/*", "/v1/messages*", "/messages*", - "/v1/skills/*", "/v1/a2a/*", + "/v1/skills/*", "/v1/a2a/*", "/api/event_logging*", "/v1/rerank*", "/v2/rerank*", "/rerank*", "/v1/ocr*", "/ocr*", "/v1/rag/*", "/rag/*", diff --git a/terraform/litellm/gcp/locals.tf b/terraform/litellm/gcp/locals.tf index d263c781449..e82892b27cb 100644 --- a/terraform/litellm/gcp/locals.tf +++ b/terraform/litellm/gcp/locals.tf @@ -43,7 +43,7 @@ locals { "/v1/models*", "/models*", "/openai/*", "/engines/*", "/v1/messages*", "/messages*", - "/v1/skills/*", "/v1/a2a/*", + "/v1/skills/*", "/v1/a2a/*", "/api/event_logging*", "/v1/rerank*", "/v2/rerank*", "/rerank*", "/v1/ocr*", "/ocr*", "/v1/rag/*", "/rag/*", diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 8f40ed6dfb7..079bb7d8667 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -16,6 +16,7 @@ longer signal it. ### Added +- **key**: Computed `server_metadata` attribute on `litellm_key` exposing every metadata entry the proxy stores, so metadata created outside Terraform is visible in state and drift on it shows on refresh, while `metadata` keeps tracking only the declared entries and updates keep preserving undeclared ones - **team_member_add**: `tpm_limit`, `rpm_limit`, `budget_duration`, and `allowed_models` attributes on `litellm_team_member_add`, applied to every member of the resource; `budget_duration` and `allowed_models` ride on `/team/member_add`, while the limits are sent through `/team/member_update`, which is where the proxy accepts them - **team**: Optional `team_id` argument on `litellm_team`, so teams can be created with a stable, human-readable ID instead of a provider-generated UUID; changing it forces replacement - `litellm_jwt_key_mapping` accepts `token_id` as an alternative to `key`, so a diff --git a/terraform/provider/docs/resources/key.md b/terraform/provider/docs/resources/key.md index 0ef0688830f..af2f38b5352 100644 --- a/terraform/provider/docs/resources/key.md +++ b/terraform/provider/docs/resources/key.md @@ -120,6 +120,8 @@ In addition to all arguments above, the following attributes are exported: * `key` - The generated API key. This is the actual key value that will be used for authentication. +* `server_metadata` - Map of every metadata entry the proxy stores for this key, including entries not declared in `metadata`, so drift on them is visible on refresh. Entries already exposed as their own attributes (`model_rpm_limit`, `model_tpm_limit`, `tags`, `guardrails`, `enforced_params`, `allowed_passthrough_routes`, `rpm_limit_type`, `tpm_limit_type`, `prompts`) are omitted and non-string values are JSON encoded. Terraform never writes it; `metadata` still tracks only the entries declared in the configuration. + * `spend` - The current spend for this key. This reflects the total amount spent using this key so far. ## State Management diff --git a/terraform/provider/litellm/resource_key.go b/terraform/provider/litellm/resource_key.go index 39546d588df..b471cab73e8 100644 --- a/terraform/provider/litellm/resource_key.go +++ b/terraform/provider/litellm/resource_key.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log" + "slices" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -61,6 +62,12 @@ func resourceKey() *schema.Resource { Optional: true, Elem: &schema.Schema{Type: schema.TypeString}, }, + "server_metadata": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Every metadata entry the proxy stores for this key, including ones not declared in metadata. Read-only, so drift on undeclared entries shows up on refresh without Terraform taking ownership of them. Entries the provider already exposes as their own attributes (model_rpm_limit, model_tpm_limit, tags, guardrails, enforced_params, allowed_passthrough_routes, rpm_limit_type, tpm_limit_type, prompts) are omitted, and non-string values are JSON encoded", + }, "tpm_limit": { Type: schema.TypeInt, Optional: true, @@ -304,6 +311,7 @@ func resourceKeyRead(ctx context.Context, d *schema.ResourceData, m interface{}) return nil } + d.Set("server_metadata", serverKeyMetadata(key.Metadata)) key.Metadata = declaredKeyMetadata(key.Metadata, d.Get("metadata").(map[string]interface{})) mapKeyToResourceData(d, key) return nil @@ -395,6 +403,25 @@ func declaredKeyMetadata(server, declared map[string]interface{}) map[string]int return result } +func serverKeyMetadata(server map[string]interface{}) map[string]string { + result := make(map[string]string, len(server)) + for k, v := range server { + if slices.Contains(keyFieldsStoredInMetadata, k) { + continue + } + if s, ok := v.(string); ok { + result[k] = s + continue + } + encoded, err := json.Marshal(v) + if err != nil { + continue + } + result[k] = string(encoded) + } + return result +} + func mergeKeyMetadata(server, oldDeclared, newDeclared map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}, len(server)+len(newDeclared)) for k, v := range server { diff --git a/terraform/provider/litellm/resource_key_test.go b/terraform/provider/litellm/resource_key_test.go index fe708edd3d3..b6e67360ad0 100644 --- a/terraform/provider/litellm/resource_key_test.go +++ b/terraform/provider/litellm/resource_key_test.go @@ -600,6 +600,12 @@ func TestKeyUpdateWithoutMetadataChangePreservesServerMetadata(t *testing.T) { if got := newState.Attributes["metadata.a"]; got != "1" { t.Errorf("metadata.a = %q, want 1", got) } + if got := newState.Attributes["server_metadata.server_side"]; got != "x" { + t.Errorf("server_metadata.server_side = %q, want x", got) + } + if _, present := proxy.updates[0]["server_metadata"]; present { + t.Errorf("computed server_metadata was sent on /key/update: %v", proxy.updates[0]["server_metadata"]) + } } func TestKeyUpdateWithMetadataChangeMergesOverServerMetadata(t *testing.T) { @@ -654,6 +660,32 @@ func TestKeyReadKeepsOnlyDeclaredMetadata(t *testing.T) { } } +func TestKeyReadExposesUndeclaredMetadataInServerMetadata(t *testing.T) { + proxy := &fakeKeyProxy{metadata: map[string]interface{}{ + "a": "1", + "server_side": "x", + "model_rpm_limit": map[string]interface{}{"gpt-4o-mini": float64(5)}, + "nested": map[string]interface{}{"k": "v"}, + }} + srv := httptest.NewServer(proxy.handler()) + defer srv.Close() + client := NewClient(srv.URL, "test-key", true) + + d := newKeyResourceData(t, map[string]interface{}{"metadata": map[string]interface{}{"a": "1"}}) + d.SetId("hash-1") + if diags := resourceKeyRead(context.Background(), d, client); diags.HasError() { + t.Fatalf("Read returned error: %v", diags) + } + + if got, want := d.Get("metadata"), map[string]interface{}{"a": "1"}; !reflect.DeepEqual(got, want) { + t.Errorf("metadata in state = %v, want %v", got, want) + } + want := map[string]interface{}{"a": "1", "server_side": "x", "nested": `{"k":"v"}`} + if got := d.Get("server_metadata"); !reflect.DeepEqual(got, want) { + t.Errorf("server_metadata in state = %v, want %v", got, want) + } +} + func TestKeyUpdateSendsChangedDuration(t *testing.T) { proxy := &fakeKeyProxy{metadata: map[string]interface{}{}} srv := httptest.NewServer(proxy.handler()) diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py index 4d5a73779ea..ab046674eb6 100644 --- a/tests/_vcr_conftest_common.py +++ b/tests/_vcr_conftest_common.py @@ -8,6 +8,7 @@ from __future__ import annotations import ast import atexit import hashlib +import inspect import json import os import re @@ -15,10 +16,18 @@ import socket import sys import threading from collections import defaultdict -from typing import Iterable +from collections.abc import Iterable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from unittest import mock +import aiohttp import pytest +import vcr import vcr.matchers as _vcr_matchers +import vcr.patch as _vcr_patch from tests._vcr_redis_persister import ( MAX_EPISODES_PER_CASSETTE, @@ -127,9 +136,7 @@ def emit_vcr_diagnostic_log(terminalreporter) -> None: with open(path, "r", encoding="utf-8") as fh: content = fh.read() except OSError as exc: - read_errors.append( - f" [failed to read {name}: {type(exc).__name__}: {exc}]" - ) + read_errors.append(f" [failed to read {name}: {type(exc).__name__}: {exc}]") continue for line in content.splitlines(): if not line.strip(): @@ -142,9 +149,7 @@ def emit_vcr_diagnostic_log(terminalreporter) -> None: return terminalreporter.write_sep("=", "VCR DIAGNOSTIC LOG", bold=True) - terminalreporter.write_line( - f" source dir: {directory} (deduplicated; full log archived as a CI artifact)" - ) + terminalreporter.write_line(f" source dir: {directory} (deduplicated; full log archived as a CI artifact)") for line in read_errors: terminalreporter.write_line(line) @@ -235,9 +240,7 @@ def pin_httpx_multipart_boundary(monkeypatch) -> None: boundary = VCR_FIXED_MULTIPART_BOUNDARY.encode("ascii") return _original_init(self, data=data, files=files, boundary=boundary, **kwargs) - monkeypatch.setattr( - _httpx_multipart.MultipartStream, "__init__", _init_with_fixed_boundary - ) + monkeypatch.setattr(_httpx_multipart.MultipartStream, "__init__", _init_with_fixed_boundary) @pytest.fixture(scope="session", autouse=True) @@ -270,11 +273,7 @@ def _replace_b64_json_in_place(obj) -> bool: changed = False if isinstance(obj, dict): for key, value in obj.items(): - if ( - key == "b64_json" - and isinstance(value, str) - and len(value) > len(VCR_IMAGE_B64_PLACEHOLDER) - ): + if key == "b64_json" and isinstance(value, str) and len(value) > len(VCR_IMAGE_B64_PLACEHOLDER): obj[key] = VCR_IMAGE_B64_PLACEHOLDER changed = True elif _replace_b64_json_in_place(value): @@ -296,16 +295,12 @@ def _strip_image_b64_payloads(response): preserves all those checks while shrinking cassettes by ~99%. """ if not isinstance(response, dict): - vcr_diag_write_line( - f"[vcr-strip-b64] response is {type(response).__name__!r}, not " - "dict; skipping b64 scrub" - ) + vcr_diag_write_line(f"[vcr-strip-b64] response is {type(response).__name__!r}, not dict; skipping b64 scrub") return response body = response.get("body") if not isinstance(body, dict): vcr_diag_write_line( - f"[vcr-strip-b64] response['body'] is {type(body).__name__!r}, " - "not dict; skipping b64 scrub" + f"[vcr-strip-b64] response['body'] is {type(body).__name__!r}, not dict; skipping b64 scrub" ) return response raw = body.get("string") @@ -316,10 +311,7 @@ def _strip_image_b64_payloads(response): try: text = bytes(raw).decode("utf-8") except UnicodeDecodeError: - vcr_diag_write_line( - "[vcr-strip-b64] response body bytes are not valid UTF-8; " - "skipping b64 scrub" - ) + vcr_diag_write_line("[vcr-strip-b64] response body bytes are not valid UTF-8; skipping b64 scrub") return response was_bytes = True elif isinstance(raw, str): @@ -327,8 +319,7 @@ def _strip_image_b64_payloads(response): was_bytes = False else: vcr_diag_write_line( - f"[vcr-strip-b64] response['body']['string'] is " - f"{type(raw).__name__!r}, not bytes/str; skipping b64 scrub" + f"[vcr-strip-b64] response['body']['string'] is {type(raw).__name__!r}, not bytes/str; skipping b64 scrub" ) return response @@ -349,9 +340,7 @@ def _strip_image_b64_payloads(response): for key in list(headers): if str(key).lower() == "content-length": value = headers[key] - headers[key] = ( - [new_len_value] if isinstance(value, list) else new_len_value - ) + headers[key] = [new_len_value] if isinstance(value, list) else new_len_value return response @@ -409,15 +398,11 @@ def _canonical_body(request) -> tuple[bytes, str]: # selected. This mirrors the existing SigV4 / multipart-boundary / b64-image # normalizations already in this module, and means the already-bloated # cassettes start replaying immediately without a flush + re-record. -_VCR_UUID_RE = re.compile( - rb"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" -) +_VCR_UUID_RE = re.compile(rb"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") _VCR_LITELLM_BATCH_JOB_RE = re.compile(rb"litellm-batch-[0-9a-fA-F]{8}") # ISO-8601 timestamps, e.g. ``2026-05-25T03:40:37.262045Z`` / # ``2026-05-25T03:40:37+00:00``. -_VCR_ISO_TS_RE = re.compile( - rb"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?" -) +_VCR_ISO_TS_RE = re.compile(rb"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?") # Unix epoch as 13-digit milliseconds, then 10-digit ``time.time()`` float, # then 10-digit integer seconds. Anchored to ``1`` + 9/12 digits, which keeps # them inside the 2001-2033 / 2001-2033 epoch windows and avoids matching @@ -639,10 +624,7 @@ def _should_drop_telemetry_record(request) -> bool: return False if not _is_telemetry_request(request): return False - if ( - _is_telemetry_export_request(request) - and not _current_test_replays_telemetry_export() - ): + if _is_telemetry_export_request(request) and not _current_test_replays_telemetry_export(): return True return not _current_test_records_telemetry() @@ -767,9 +749,7 @@ def _iter_header_values(headers, name: str): yield value -_AWS_SIGV4_CREDENTIAL_RE = re.compile( - r"AWS4-HMAC-SHA256\s+Credential=([^/\s,]+)/", re.IGNORECASE -) +_AWS_SIGV4_CREDENTIAL_RE = re.compile(r"AWS4-HMAC-SHA256\s+Credential=([^/\s,]+)/", re.IGNORECASE) # Google OAuth2 access tokens always start with ``ya29.`` regardless of how # they were minted (service account, metadata server, impersonation). @@ -891,9 +871,7 @@ def _normalize_multipart_boundary(request) -> None: return try: - headers[content_type_key] = content_type_value.replace( - match.group(0), fixed_param - ) + headers[content_type_key] = content_type_value.replace(match.group(0), fixed_param) except (TypeError, AttributeError): return @@ -985,8 +963,7 @@ def _materialize_iterable_body(request) -> None: uri = getattr(request, "uri", getattr(request, "url", "?")) first_type = type(chunks[0]).__name__ if chunks else "empty" vcr_diag_write_line( - f"[vcr-materialize] FALLBACK: {method} {uri} chunk type " - f"{first_type!r} not coerced to bytes; storing b''" + f"[vcr-materialize] FALLBACK: {method} {uri} chunk type {first_type!r} not coerced to bytes; storing b''" ) out = b"" @@ -1026,9 +1003,7 @@ def _key_fingerprint_matcher(r1, r2) -> None: return def _fp(req): - for value in _iter_header_values( - getattr(req, "headers", None), KEY_FINGERPRINT_HEADER - ): + for value in _iter_header_values(getattr(req, "headers", None), KEY_FINGERPRINT_HEADER): if value is None: continue return value if isinstance(value, str) else str(value) @@ -1159,13 +1134,11 @@ def _print_atexit_banner() -> None: _emit("VCR CASSETTE CACHE DEGRADED") if save_failures: _emit( - f" {save_failures} cassette save failure(s); last error: " - f"{health.get('save_failure_last_error', '')}" + f" {save_failures} cassette save failure(s); last error: {health.get('save_failure_last_error', '')}" ) if load_failures: _emit( - f" {load_failures} cassette load failure(s); last error: " - f"{health.get('load_failure_last_error', '')}" + f" {load_failures} cassette load failure(s); last error: {health.get('load_failure_last_error', '')}" ) if snapshot: _emit(_format_capacity_line(snapshot)) @@ -1276,11 +1249,7 @@ class _RespxUsageVisitor(ast.NodeVisitor): if isinstance(dec, ast.Call): dec = dec.func if isinstance(dec, ast.Attribute): - return ( - isinstance(dec.value, ast.Name) - and dec.value.id == "respx" - and dec.attr == "mock" - ) + return isinstance(dec.value, ast.Name) and dec.value.id == "respx" and dec.attr == "mock" return False def _is_pytest_mark_respx(self, dec: ast.expr) -> bool: @@ -1307,9 +1276,7 @@ class _RespxUsageVisitor(ast.NodeVisitor): # ``def test_foo(respx_mock): ...`` — pytest supplies the fixture # whenever the parameter name appears, regardless of marker. all_args = ( - list(args.args) - + list(args.kwonlyargs) - + (list(args.posonlyargs) if hasattr(args, "posonlyargs") else []) + list(args.args) + list(args.kwonlyargs) + (list(args.posonlyargs) if hasattr(args, "posonlyargs") else []) ) for a in all_args: if a.arg == "respx_mock": @@ -1566,9 +1533,7 @@ def _emit_outcome_payload( }, ) ) - node.user_properties.append( - (_USER_PROP_RECORDED_BY, os.environ.get("PYTEST_XDIST_WORKER", "")) - ) + node.user_properties.append((_USER_PROP_RECORDED_BY, os.environ.get("PYTEST_XDIST_WORKER", ""))) def aggregate_report_outcome(report) -> None: @@ -1616,9 +1581,7 @@ def aggregate_report_outcome(report) -> None: if verdict == VERDICT_MISS_OVERFLOW: _session_stats["overflow_tests"].append(nodeid) elif verdict == VERDICT_UNMARKED_LIVE_CALL: - _session_stats["unmarked_live_call_tests"].append( - (nodeid, list(outcome.get("live_call_hosts") or [])) - ) + _session_stats["unmarked_live_call_tests"].append((nodeid, list(outcome.get("live_call_hosts") or []))) skip_reason = outcome.get("skip_reason") if skip_reason: @@ -1635,9 +1598,7 @@ def session_stats_snapshot() -> dict: "overflow_tests": list(_session_stats["overflow_tests"]), "unmarked_live_call_tests": list(_session_stats["unmarked_live_call_tests"]), "skip_reason_counts": dict(_session_stats["skip_reason_counts"]), - "skip_reason_examples": { - k: list(v) for k, v in _session_stats["skip_reason_examples"].items() - }, + "skip_reason_examples": {k: list(v) for k, v in _session_stats["skip_reason_examples"].items()}, } @@ -1810,9 +1771,7 @@ def record_vcr_outcome(request, vcr) -> None: # Cassette is None ⇒ test wasn't VCR-marked. Honor the skip reason # we tagged at collection time, and pull live-call hosts captured by # the socket probe (if any). - skip_reason = getattr( - request.node, VCR_SKIP_REASON_USER_ATTR, SKIP_REASON_FILE_OPT_OUT - ) + skip_reason = getattr(request.node, VCR_SKIP_REASON_USER_ATTR, SKIP_REASON_FILE_OPT_OUT) _session_stats["skip_reason_counts"][skip_reason] += 1 hosts = getattr(request.node, _LIVE_CALL_BUFFER_KEY, []) or [] @@ -1837,9 +1796,7 @@ def record_vcr_outcome(request, vcr) -> None: live_call_hosts=hosts, ) if vcr_outcome_logging_enabled(): - request.node.user_properties.append( - (_USER_PROP_VERDICT_LINE, _format_verdict_line(verdict, None, extra)) - ) + request.node.user_properties.append((_USER_PROP_VERDICT_LINE, _format_verdict_line(verdict, None, extra))) def install_live_call_probe(request, vcr) -> None: @@ -1858,9 +1815,7 @@ def install_live_call_probe(request, vcr) -> None: # Track the current test for telemetry-leak suppression (applies to every # test, VCR-marked or not). See ``_should_drop_telemetry_record``. global _current_test_nodeid - _current_test_nodeid = str( - getattr(getattr(request, "node", None), "nodeid", "") or "" - ) + _current_test_nodeid = str(getattr(getattr(request, "node", None), "nodeid", "") or "") if vcr is not None or vcr_disabled(): return None probe = _LiveCallProbe() @@ -1876,10 +1831,7 @@ def _format_capacity_line(snapshot: dict) -> str: pct = float(snapshot.get("used_pct", 0.0) or 0.0) used_mb = used / (1024 * 1024) cap_mb = cap / (1024 * 1024) - return ( - f" Cassette Redis usage: {used_mb:.1f} MiB / {cap_mb:.1f} MiB " - f"({pct:.1f}% of maxmemory)" - ) + return f" Cassette Redis usage: {used_mb:.1f} MiB / {cap_mb:.1f} MiB ({pct:.1f}% of maxmemory)" def emit_vcr_classification_summary(terminalreporter) -> None: @@ -1940,14 +1892,10 @@ def emit_vcr_classification_summary(terminalreporter) -> None: total_leaks = sum(leak_counts.values()) terminalreporter.write_sep("-", "VCR COST LEAK CHECK", bold=True) if total_leaks: - rendered = ", ".join( - f"{verdict}={count}" for verdict, count in leak_counts.items() if count - ) + rendered = ", ".join(f"{verdict}={count}" for verdict, count in leak_counts.items() if count) terminalreporter.write_line(f" FAIL: {rendered}") else: - terminalreporter.write_line( - " PASS: no overflow, partial, not-persisted, or unmarked live-call verdicts" - ) + terminalreporter.write_line(" PASS: no overflow, partial, not-persisted, or unmarked live-call verdicts") overflow = snapshot["overflow_tests"] if overflow: @@ -2007,18 +1955,14 @@ def emit_cassette_cache_session_banner(terminalreporter) -> None: snapshot = cassette_cache_capacity_snapshot() if save_failures or load_failures: - terminalreporter.write_sep( - "=", "VCR CASSETTE CACHE DEGRADED", red=True, bold=True - ) + terminalreporter.write_sep("=", "VCR CASSETTE CACHE DEGRADED", red=True, bold=True) if save_failures: terminalreporter.write_line( - f" {save_failures} cassette save failure(s); last error: " - f"{health.get('save_failure_last_error', '')}" + f" {save_failures} cassette save failure(s); last error: {health.get('save_failure_last_error', '')}" ) if load_failures: terminalreporter.write_line( - f" {load_failures} cassette load failure(s); last error: " - f"{health.get('load_failure_last_error', '')}" + f" {load_failures} cassette load failure(s); last error: {health.get('load_failure_last_error', '')}" ) terminalreporter.write_line( " Tests still passed because cassette persistence is best-effort, " @@ -2031,9 +1975,7 @@ def emit_cassette_cache_session_banner(terminalreporter) -> None: return if snapshot and snapshot["used_pct"] >= CASSETTE_CACHE_HIGH_WATER_FRACTION * 100: - terminalreporter.write_sep( - "=", "VCR CASSETTE CACHE NEAR CAPACITY", yellow=True, bold=True - ) + terminalreporter.write_sep("=", "VCR CASSETTE CACHE NEAR CAPACITY", yellow=True, bold=True) terminalreporter.write_line(_format_capacity_line(snapshot)) terminalreporter.write_line( " No save failures yet, but Redis is approaching maxmemory. " @@ -2082,13 +2024,104 @@ class VerboseReporterState: if reporter is None: return verdict = next( - ( - v - for k, v in (report.user_properties or []) - if k == _USER_PROP_VERDICT_LINE - ), + (v for k, v in (report.user_properties or []) if k == _USER_PROP_VERDICT_LINE), None, ) if not verdict: return reporter.write_line(f"{verdict} :: {report.nodeid}") + + +@dataclass(frozen=True, slots=True) +class VcrPatchPoint: + owner: object + attribute: str + original: object + + @property + def name(self) -> str: + return f"{_patch_owner_name(self.owner)}.{self.attribute}" + + def current(self) -> object: + current: Final[object] = getattr(self.owner, self.attribute) + return current + + def is_patched(self) -> bool: + return self.current() is not self.original + + def restore(self) -> None: + setattr(self.owner, self.attribute, self.original) + + +def _patch_owner_name(owner: object) -> str: + if inspect.isclass(owner): + return f"{owner.__module__}.{owner.__qualname__}" + if inspect.ismodule(owner): + return owner.__name__ + return repr(owner) + + +def _vcr_patch_point(patcher: mock._patch[object]) -> VcrPatchPoint: + owner: Final[object] = patcher.getter() + return VcrPatchPoint(owner=owner, attribute=patcher.attribute, original=patcher.new) + + +_VCR_PATCH_POINTS: Final = ( + *(_vcr_patch_point(patcher) for patcher in _vcr_patch.reset_patchers()), + VcrPatchPoint(aiohttp.ClientSession, "_request", _vcr_patch._AiohttpClientSessionRequest), +) + + +@dataclass(frozen=True, slots=True) +class VcrPatchLeak: + patch_points: tuple[str, ...] + cassette_paths: tuple[str, ...] + + +def _cassette_paths_wrapped_into(fn: object) -> tuple[str, ...]: + if not inspect.isfunction(fn): + return () + cassette: Final = inspect.getclosurevars(fn).nonlocals.get("cassette") + own: Final = (str(cassette._path),) if isinstance(cassette, vcr.cassette.Cassette) else () + return own + _cassette_paths_wrapped_into(getattr(fn, "__wrapped__", None)) + + +def detect_vcr_patch_leak() -> VcrPatchLeak | None: + leaked: Final = tuple(point for point in _VCR_PATCH_POINTS if point.is_patched()) + if not leaked: + return None + return VcrPatchLeak( + patch_points=tuple(point.name for point in leaked), + cassette_paths=tuple( + dict.fromkeys(path for point in leaked for path in _cassette_paths_wrapped_into(point.current())) + ), + ) + + +def restore_vcr_patch_points() -> None: + for point in _VCR_PATCH_POINTS: + point.restore() + + +def guard_vcr_patch_points(item: pytest.Item, teardown_failed: bool) -> None: + leak: Final = detect_vcr_patch_leak() + if leak is None: + return + restore_vcr_patch_points() + if teardown_failed: + return + pytest.fail( + f"{item.nodeid} finished with a vcrpy cassette still patched into " + f"{', '.join(leak.patch_points)} (cassettes: {', '.join(leak.cassette_paths) or 'unknown'}); " + "the originals were restored so later tests are unaffected", + pytrace=False, + ) + + +@contextmanager +def rewound_new_episodes_cassette(cassette_dir: Path) -> Iterator[vcr.cassette.Cassette]: + cassette_path: Final = cassette_dir / "rewound_owner.yaml" + cassette_path.write_text("interactions: []\nversion: 1\n") + recorder: Final = vcr.VCR(cassette_library_dir=str(cassette_dir)) + with recorder.use_cassette(cassette_path.name, record_mode="new_episodes") as cassette: + yield cassette diff --git a/tests/capturing_transport.py b/tests/capturing_transport.py new file mode 100644 index 00000000000..496c286685c --- /dev/null +++ b/tests/capturing_transport.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Final + +import httpx +from pydantic import BaseModel, TypeAdapter + +_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object]) + + +class CapturingTransport(httpx.AsyncBaseTransport, httpx.BaseTransport): + def __init__(self, response: BaseModel) -> None: + self._response: Final = response + self.request_bodies: tuple[Mapping[str, object], ...] = () + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return self._respond(request.read()) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return self._respond(await request.aread()) + + def _respond(self, body: bytes) -> httpx.Response: + self.request_bodies = (*self.request_bodies, _JSON_OBJECT.validate_json(body)) + return httpx.Response(200, json=self._response.model_dump(mode="json")) diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index b00b7dfac95..ad1e0322787 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -210,14 +210,15 @@ quota_management... chat_completions | stream | messages_bridge | embeddings | cache_hit | key_rollup | concurrent_burst | tags | end_user | per_model | failure | spend_calculate | pagination | key_attribution + | websearch_interception assertion : blocks_over_limit | resets_after_window | headers_report_remaining | picks_under_tpm | blocks_then_resets | resets_windows_independently | alerts_without_blocking | isolates_per_model | isolates_per_member | isolates_per_group | enforced_across_keys | routes_to_fallback | reseed_matches_db | reports_spend | logs_cost | zero_cost | matches_sum_of_logs | loses_no_spend | attributes_spend | writes_own_rows - | writes_failure_row | returns_cost | keeps_total | joins_key | reports_alias_and_email + | writes_failure_row | attributes_provider | returns_cost | keeps_total | joins_key | reports_alias_and_email | health_rows_keep_service_account | retrieve_batch_cost_joins_retrieving_key - | poller_batch_cost_joins_creating_key + | poller_batch_cost_joins_creating_key | bills_under_request_session e.g. quota_management.ratelimit.rpm.blocks_over_limit exercised_on=[chat_completions, messages] quota_management.budget.key.blocks_over_limit exercised_on=[chat_completions] ``` diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index ca0fbd84c35..a2398ef7c3c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -30,6 +30,7 @@ from e2e_config import ( FIXTURE_MODE_RAW, MANAGED_FILES_OPT_IN_ENV, MCP_OAUTH_LIVE_OPT_IN_ENV, + OTEL_TLS_OPT_IN_ENV, OTEL_V2_OPT_IN_ENV, PROMPT_CACHING_OPT_IN_ENV, PROVIDER_EDGE_HOST_OPT_IN_ENV, @@ -63,6 +64,7 @@ OPT_IN_MARKERS: Final = MappingProxyType( "mcp_oauth_live": MCP_OAUTH_LIVE_OPT_IN_ENV, "provider_edge_host": PROVIDER_EDGE_HOST_OPT_IN_ENV, "otel_v2": OTEL_V2_OPT_IN_ENV, + "otel_tls": OTEL_TLS_OPT_IN_ENV, } ) @@ -156,6 +158,10 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "otel_v2: needs a proxy running with LITELLM_OTEL_V2=true; deselected unless E2E_OTEL_V2 is set", ) + config.addinivalue_line( + "markers", + "otel_tls: needs a stack whose gateway exports OTLP over TLS signed by the CA in SSL_CERT_FILE; deselected unless E2E_OTEL_EXPORTER_ENDPOINT is set", + ) def pytest_sessionstart(session: pytest.Session) -> None: diff --git a/tests/e2e/coverage_registry/guardrail.yaml b/tests/e2e/coverage_registry/guardrail.yaml index 02b5a921add..f49568c883b 100644 --- a/tests/e2e/coverage_registry/guardrail.yaml +++ b/tests/e2e/coverage_registry/guardrail.yaml @@ -10,6 +10,7 @@ - {id: guardrail.litellm_content_filter.pre_call.blocks, module: guardrail, tier: P0, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "test_team_disable_global_guardrail_e2e.py", rationale: "Local content-filter default-on blocks banned keyword pre-call"} - {id: guardrail.litellm_content_filter.pre_call.blocks_video, module: guardrail, tier: P0, hook_point: pre_call, assertions: [blocks], exercised_on: [videos], source: "test_key_guardrail_video_e2e.py", fail_before_fix: proven, rationale: "A content-filter guardrail attached to a key (metadata.guardrails) blocks a banned prompt on POST /v1/videos before the provider is called; before the fix the route's call type was unknown to the unified guardrail hook and the prompt went to the provider unscanned (LIT-6685)"} - {id: guardrail.litellm_content_filter.pre_call.allows, module: guardrail, tier: P0, hook_point: pre_call, assertions: [allows], exercised_on: [chat_completions], source: "test_team_disable_global_guardrail_e2e.py", rationale: "Team disable_global_guardrails bypasses default-on content filter"} +- {id: guardrail.litellm_content_filter.pre_call.returns_guardrail_information, module: guardrail, tier: P0, hook_point: pre_call, assertions: [allows], exercised_on: [chat_completions], source: "guardrails/test_guardrail_information_response_e2e.py", rationale: "Opt-in chat responses expose successful guardrail execution details"} - {id: guardrail.litellm_content_filter.apply_endpoint.blocks, module: guardrail, tier: P0, hook_point: apply_endpoint, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_endpoints.py:apply_guardrail", rationale: "POST /guardrails/apply_guardrail blocks banned content for customers that call the apply surface directly"} - {id: guardrail.litellm_content_filter.apply_endpoint.allows, module: guardrail, tier: P0, hook_point: apply_endpoint, assertions: [allows], exercised_on: [chat_completions], source: "guardrail_endpoints.py:apply_guardrail", rationale: "POST /guardrails/apply_guardrail returns clean text for allowed input"} - {id: guardrail.bedrock.during.blocks, module: guardrail, tier: P0, hook_point: during, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/bedrock_guardrails.py", rationale: "During-call moderation for streaming"} diff --git a/tests/e2e/coverage_registry/logging.yaml b/tests/e2e/coverage_registry/logging.yaml index 1f2f1d64711..7c83e4d3aea 100644 --- a/tests/e2e/coverage_registry/logging.yaml +++ b/tests/e2e/coverage_registry/logging.yaml @@ -13,6 +13,7 @@ - {id: logging.otel.failure.exports_metric, module: logging, tier: P0, event: failure, assertions: [exports_metric], exercised_on: [chat_completions, messages], source: "integrations/otel/logger.py", rationale: "Error spans for observability continuity"} - {id: logging.braintrust.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/braintrust_logging.py", rationale: "Evals platform spend"} - {id: logging.langsmith.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, messages], source: "integrations/langsmith.py", rationale: "LangChain ecosystem"} +- {id: logging.langsmith.success.serializes_non_native_metadata, module: logging, tier: P1, event: success, assertions: [serializes_non_native_metadata], exercised_on: [sdk], source: "integrations/langsmith.py", rationale: "datetime/Decimal/UUID metadata used to TypeError in json.dumps and drop the whole batch (LIT-8310)"} - {id: logging.arize.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions, embeddings], source: "integrations/arize/arize.py", rationale: "ML-ops observability"} - {id: logging.mlflow.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions], source: "integrations/mlflow.py", rationale: "Experiment tracking cost/run"} - {id: logging.opik.success.logs_spend, module: logging, tier: P1, event: success, assertions: [logs_spend], exercised_on: [chat_completions], source: "integrations/opik/opik.py", rationale: "Eval platform spend/case"} diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index 85fbd0acd91..e1a840b1239 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -94,6 +94,10 @@ - {id: mgmt.mcp_toolset.update.persists, module: mgmt, tier: P0, surface: api, assertions: [persists], source: "mcp_management_endpoints.py:3098", rationale: "Narrowing the tools to one entry reads back exactly that entry"} - {id: mgmt.mcp_toolset.update.clear_persists, module: mgmt, tier: P0, surface: api, assertions: [clear_persists], source: "mcp_management_endpoints.py:3098", fail_before_fix: proven, rationale: "An explicit null clears the stored description; the update used to drop null and keep the old value"} - {id: mgmt.mcp_toolset.delete.persists, module: mgmt, tier: P0, surface: api, assertions: [persists], source: "mcp_management_endpoints.py:3149", rationale: "A deleted toolset is gone by id and from the list on every replica"} +- {id: mgmt.key.delete.audit_logged, module: mgmt, tier: P0, surface: api, assertions: [audit_logged], source: "key_management_endpoints.py:3981", rationale: "Every hard key deletion writes one LiteLLM_VerificationToken deleted audit row, whether the key is addressed by key or by alias"} +- {id: mgmt.team.member_delete.audit_logs_keys, module: mgmt, tier: P0, surface: api, assertions: [audit_logs_keys], source: "team_endpoints.py:3563", rationale: "Removing a team member hard-deletes their keys and each deleted key writes a deleted audit row"} +- {id: mgmt.team.delete.audit_logs_keys, module: mgmt, tier: P0, surface: api, assertions: [audit_logs_keys], source: "team_endpoints.py:4344", rationale: "Deleting a team hard-deletes its keys and each deleted key writes a deleted audit row"} +- {id: mgmt.user.delete.audit_logs_keys, module: mgmt, tier: P0, surface: api, assertions: [audit_logs_keys], source: "internal_user_endpoints.py:2369", rationale: "Deleting a user hard-deletes their keys and each deleted key writes a deleted audit row"} - {id: mgmt.user.jwt.database_roles, module: mgmt, tier: P0, surface: api, assertions: [database_roles], source: "auth/handle_jwt.py", rationale: "User-only JWT subjects retain their seeded database roles and memberships"} - {id: mgmt.key.jwt.viewer_denied, module: mgmt, tier: P0, surface: api, assertions: [viewer_denied], source: "auth/route_checks.py", rationale: "An admin viewer can read a key but cannot update it or change stored state"} diff --git a/tests/e2e/coverage_registry/quota_management.yaml b/tests/e2e/coverage_registry/quota_management.yaml index ad0914d455b..5ea48fdcd9d 100644 --- a/tests/e2e/coverage_registry/quota_management.yaml +++ b/tests/e2e/coverage_registry/quota_management.yaml @@ -10,6 +10,7 @@ - {id: quota_management.ratelimit.redis_backed.blocks_over_limit, module: quota_management, tier: P0, behavior: ratelimit, variant: redis_backed, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "parallel_request_limiter_v3.py", rationale: "With Redis configured, RPM still enforces 429 across the shared limiter path customers run multi-replica"} - {id: quota_management.ratelimit.rpm.resets_after_window, module: quota_management, tier: P1, behavior: ratelimit, variant: rpm, assertions: [resets_after_window], exercised_on: [chat_completions], source: "parallel_request_limiter_v3.py", rationale: "Rate-limit window (LITELLM_RATE_LIMIT_WINDOW_SIZE, 60s default) expires; a blocked key serves again in the next window"} - {id: quota_management.ratelimit.rpm.headers_report_remaining, module: quota_management, tier: P1, behavior: ratelimit, variant: rpm, assertions: [headers_report_remaining], exercised_on: [chat_completions], source: "parallel_request_limiter_v3.py async_post_call_success_hook", rationale: "Successful responses carry x-ratelimit-api_key-{limit,remaining}-{requests,tokens} so clients can pace"} +- {id: quota_management.ratelimit.model_group_alias.shares_bucket, module: quota_management, tier: P1, behavior: ratelimit, variant: model_group_alias, assertions: [shares_bucket], exercised_on: [chat_completions], source: "parallel_request_limiter_v3.py:_add_model_per_key_rate_limit_descriptor", rationale: "A model_group_alias draws on the same per-key deployment rpm bucket as the resolved model group, so alias plus real-name traffic cannot exceed the configured limit"} - {id: quota_management.ratelimit.priority_generous.picks_under_tpm, module: quota_management, tier: P1, behavior: ratelimit, variant: priority_generous, assertions: [picks_under_tpm], exercised_on: [chat_completions, messages], source: "dynamic_rate_limiter_v3.py:36-52", rationale: "Generous mode (<80% sat) allows priority borrowing"} - {id: quota_management.ratelimit.priority_strict.picks_under_tpm, module: quota_management, tier: P1, behavior: ratelimit, variant: priority_strict, assertions: [picks_under_tpm], exercised_on: [chat_completions, messages], source: "dynamic_rate_limiter_v3.py:53-71", rationale: "Strict mode (>=80% sat) enforces priority fairness"} - {id: quota_management.budget.key.blocks_over_limit, module: quota_management, tier: P0, behavior: budget, variant: key, assertions: [blocks_over_limit], exercised_on: [chat_completions], source: "proxy/auth/auth_checks.py", rationale: "A key's max_budget blocks further paid calls once spend crosses it"} @@ -45,10 +46,12 @@ - {id: quota_management.spend_tracking.cache_hit.zero_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: cache_hit, assertions: [zero_cost], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "A response-cache hit logs at zero cost with the cache-hit marker"} - {id: quota_management.spend_tracking.key_rollup.matches_sum_of_logs, module: quota_management, tier: P1, behavior: spend_tracking, variant: key_rollup, assertions: [matches_sum_of_logs], exercised_on: [chat_completions], source: "proxy/db/db_spend_update_writer.py", rationale: "A key's rolled-up spend equals the sum of its log rows"} - {id: quota_management.spend_tracking.concurrent_burst.loses_no_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: concurrent_burst, assertions: [loses_no_spend], exercised_on: [chat_completions], source: "proxy/db/db_spend_update_writer.py", rationale: "Concurrent calls all land as spend; no row lost to write contention"} +- {id: quota_management.spend_tracking.surface_consistency.matches_every_surface, module: quota_management, tier: P1, behavior: spend_tracking, variant: surface_consistency, assertions: [matches_every_surface], exercised_on: [chat_completions], source: "proxy/db/db_spend_update_writer.py", rationale: "One priced request lands the same response_cost on the spend log row, /key/info, /team/info, the usage export's /user/daily/activity/aggregated row, and the litellm_spend_metric Prometheus sample; each is a separate writer, so a rounding, dropped, or double-counted write on one drifts it from the rest (LIT-3620, LIT-5045)"} - {id: quota_management.spend_tracking.tags.attributes_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: tags, assertions: [attributes_spend], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Request tags round-trip to spend rows and tag rollups match tagged logs"} - {id: quota_management.spend_tracking.end_user.attributes_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: end_user, assertions: [attributes_spend], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "user= attribution lands the end-user id on the spend row"} - {id: quota_management.spend_tracking.per_model.writes_own_rows, module: quota_management, tier: P2, behavior: spend_tracking, variant: per_model, assertions: [writes_own_rows], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Each model on a shared key gets its own spend row"} - {id: quota_management.spend_tracking.failure.writes_failure_row, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [writes_failure_row], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_log_error_logger.py", rationale: "A failed call writes a failure-status spend row"} +- {id: quota_management.spend_tracking.failure.attributes_provider, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [attributes_provider], exercised_on: [chat_completions], source: "proxy/utils.py", rationale: "A request rejected in pre_call_hook (rate limit, guardrail) still lands its single deployment's provider and model_id on the failure spend row"} - {id: quota_management.spend_tracking.spend_calculate.returns_cost, module: quota_management, tier: P2, behavior: spend_tracking, variant: spend_calculate, assertions: [returns_cost], exercised_on: [spend_calculate], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "/spend/calculate prices a hypothetical request at nonzero cost"} - {id: quota_management.spend_tracking.pagination.keeps_total, module: quota_management, tier: P2, behavior: spend_tracking, variant: pagination, assertions: [keeps_total], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "Spend-logs v2 pagination caps page size without losing the total"} - {id: quota_management.spend_tracking.cache_write.bills_cache_creation_rate, module: quota_management, tier: P1, behavior: spend_tracking, variant: cache_write, assertions: [bills_cache_creation_rate], exercised_on: [chat_completions], source: "litellm_core_utils/llm_cost_calc/utils.py", rationale: "OpenAI cache-write tokens land on the spend row as cache-creation tokens billed at the cache-creation rate, not silently at the input rate (#34046)"} @@ -58,6 +61,7 @@ - {id: quota_management.spend_tracking.service_tier.bills_tier_rates, module: quota_management, tier: P1, behavior: spend_tracking, variant: service_tier, assertions: [bills_tier_rates], exercised_on: [chat_completions], source: "cost_calculator.py", rationale: "A priority service_tier call bills input, output, and reasoning at the deployment's *_priority rates and records the tier on the row (#35923, #35925)"} - {id: quota_management.spend_tracking.cost_headers.additive_components, module: quota_management, tier: P1, behavior: spend_tracking, variant: cost_headers, assertions: [additive_components], exercised_on: [chat_completions], source: "proxy/common_request_processing.py", rationale: "The x-litellm-response-cost-* component headers sum to the total, input covers only fresh tokens, and reasoning stays a subset of output (#36965)"} - {id: quota_management.spend_tracking.passthrough_stream.injects_usage_cost, module: quota_management, tier: P1, behavior: spend_tracking, variant: passthrough_stream, assertions: [injects_usage_cost], exercised_on: [openai_passthrough], source: "proxy/pass_through_endpoints/streaming_handler.py", rationale: "With include_cost_in_streaming_usage on, the /openai passthrough's final streaming usage frame carries the proxy-computed cost (#36503). Uncovered: the flag is only settable in litellm_settings, and the shared e2e stack does not turn it on yet"} +- {id: quota_management.spend_tracking.websearch_interception.bills_under_request_session, module: quota_management, tier: P1, behavior: spend_tracking, variant: websearch_interception, assertions: [bills_under_request_session], exercised_on: [messages], source: "integrations/websearch_interception/handler.py", fail_before_fix: proven, rationale: "A web_search server tool the proxy intercepts into litellm.asearch writes its own asearch spend row, and that row carries the parent request's session_id so the session view counts the search and its cost next to the turn that triggered it (LIT-8063)"} - {id: quota_management.spend_tracking.key_attribution.joins_key, module: quota_management, tier: P1, behavior: spend_tracking, variant: key_attribution, assertions: [joins_key], exercised_on: [chat_completions, messages, responses, embeddings, batches, files, google_native, rust_control_plane], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Every spend row a virtual key writes across chat, queued chat, messages, responses, embeddings, the Gemini passthrough, file upload, batch create, and a replayed callback log carries api_key equal to the key's token hash and the key alias, the join the usage APIs depend on; a re-hashed token shows up as an unattributed key-hash-* row (#39568, #39572)"} - {id: quota_management.spend_tracking.key_attribution.reports_alias_and_email, module: quota_management, tier: P1, behavior: spend_tracking, variant: key_attribution, assertions: [reports_alias_and_email], exercised_on: [chat_completions, messages, responses, embeddings, batches, files, google_native, rust_control_plane], source: "proxy/management_endpoints/internal_user_endpoints.py", rationale: "/spend/logs?api_key= returns every one of the key's rows with its alias and /user/daily/activity aggregates them under the key's token with key_alias and user_email; /spend/logs carries no email field, so the email is asserted on daily activity only"} - {id: quota_management.spend_tracking.key_attribution.health_rows_keep_service_account, module: quota_management, tier: P1, behavior: spend_tracking, variant: key_attribution, assertions: [health_rows_keep_service_account], exercised_on: [chat_completions], source: "proxy/health_check.py", rationale: "A /health probe's spend row stays keyed by the literal litellm-internal-health-check service account rather than a hash of it, so health spend never appears as an unattributed key"} diff --git a/tests/e2e/coverage_registry/reliability.yaml b/tests/e2e/coverage_registry/reliability.yaml index 334780eda53..9f88f2478c0 100644 --- a/tests/e2e/coverage_registry/reliability.yaml +++ b/tests/e2e/coverage_registry/reliability.yaml @@ -9,6 +9,7 @@ - {id: reliability.retry.auth.succeeds_within_retries, module: reliability, tier: P1, behavior: retry, variant: auth, assertions: [succeeds_within_retries], exercised_on: [chat_completions], source: "get_retry_from_policy.py:42", rationale: "Transient auth glitch retry"} - {id: reliability.retry.context_window.succeeds_within_retries, module: reliability, tier: P1, behavior: retry, variant: context_window, assertions: [succeeds_within_retries], exercised_on: [chat_completions], source: "get_retry_from_policy.py:51", fail_before_fix: proven, rationale: "A context-window 400 under BadRequestErrorRetries retries onto a sibling deployment in the same model group, instead of coming straight back as the 400 the deployment that just refused it returned"} - {id: reliability.cooldown.5xx.trips_then_recovers, module: reliability, tier: P0, behavior: cooldown, variant: "5xx", assertions: [trips_then_recovers], exercised_on: [chat_completions], source: "cooldown_handlers.py:40", rationale: "Deployment cools after repeated 5xx, recovers after cooldown_time"} +- {id: reliability.cooldown.sibling_replica.serves_backup_within_read_interval, module: reliability, tier: P1, behavior: cooldown, variant: sibling_replica, assertions: [serves_backup_within_read_interval], exercised_on: [chat_completions], source: "cooldown_cache.py:44", fail_before_fix: proven, rationale: "A bench taken on one gateway reaches a sibling that already holds the key's read timer within the 1s Redis read interval plus margin, so its next call lands on the backup"} - {id: reliability.cooldown.429.trips_then_recovers, module: reliability, tier: P0, behavior: cooldown, variant: "429", assertions: [trips_then_recovers], exercised_on: [chat_completions], source: "cooldown_handlers.py:69", rationale: "Cools on 429, avoids hammering exhausted provider"} - {id: reliability.cooldown.auth.trips_then_recovers, module: reliability, tier: P1, behavior: cooldown, variant: auth, assertions: [trips_then_recovers], exercised_on: [chat_completions], source: "cooldown_handlers.py:74", rationale: "Cools on 401 auth error"} - {id: reliability.cooldown.timeout.trips_then_recovers, module: reliability, tier: P1, behavior: cooldown, variant: timeout, assertions: [trips_then_recovers], exercised_on: [chat_completions], source: "cooldown_handlers.py:77", rationale: "Cools on 408 timeout"} diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 311c944eeb4..77395a066ac 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -34,7 +34,7 @@ CONTROL_PLANE_BASE_URL = os.environ.get( def parse_replica_urls(raw: str, fallback: str) -> tuple[str, ...]: - urls: Final = tuple(url.strip().rstrip("/") for url in raw.split(",") if url.strip()) + urls: Final = tuple(dict.fromkeys(url.strip().rstrip("/") for url in raw.split(",") if url.strip())) return urls or (fallback,) @@ -58,6 +58,7 @@ LINEAR_READONLY_TOOL: Final = "list_teams" # as listed by tools/list on mcp.lin # service in docker-compose.yml maps it to host 16686). Trace-completeness tests # read exported spans back through it. OTEL_QUERY_URL = os.environ.get("E2E_OTEL_QUERY_URL", "http://localhost:16686").rstrip("/") +OTEL_EXPORTER_ENDPOINT = os.environ.get("E2E_OTEL_EXPORTER_ENDPOINT", "") # Real-DataDog read-back (no local sink - destination fakes cannot be deployed # on the cluster): the proxy delivers with DD_API_KEY as in production, and the @@ -148,6 +149,7 @@ CLI_DETERMINISM_OPT_IN_ENV = "E2E_CLI_DETERMINISM" MCP_OAUTH_LIVE_OPT_IN_ENV: Final = "E2E_MCP_OAUTH_LIVE" PROVIDER_EDGE_HOST_OPT_IN_ENV: Final = "E2E_PROVIDER_EDGE_HOST_REACHABLE" OTEL_V2_OPT_IN_ENV: Final = "E2E_OTEL_V2" +OTEL_TLS_OPT_IN_ENV: Final = "E2E_OTEL_EXPORTER_ENDPOINT" ANOMALY_SESSIONS = int(os.environ.get("E2E_ANOMALY_SESSIONS", "6")) ANOMALY_TURNS_PER_SESSION = int(os.environ.get("E2E_ANOMALY_TURNS_PER_SESSION", "6")) ANOMALY_TURN_ATTEMPTS = int(os.environ.get("E2E_ANOMALY_TURN_ATTEMPTS", "3")) diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 97f1e1671f8..1b62a1dbd8c 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -49,6 +49,7 @@ class AnthropicHeaders(AuthHeaders): on its own internal calls.""" anthropic_version: str = Field(default="2023-06-01", alias="anthropic-version") + x_litellm_session_id: str | None = Field(default=None, serialization_alias="x-litellm-session-id") class PartialBody(BaseModel): diff --git a/tests/e2e/gateway/record_replay_ci_config.yml b/tests/e2e/gateway/record_replay_ci_config.yml index 5b3db0ff530..bc1f3f10562 100644 --- a/tests/e2e/gateway/record_replay_ci_config.yml +++ b/tests/e2e/gateway/record_replay_ci_config.yml @@ -2,3 +2,6 @@ general_settings: master_key: os.environ/LITELLM_MASTER_KEY store_model_in_db: true disable_model_info_refresh: true + +litellm_settings: + callbacks: ["prometheus"] diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml index 2d02fedddae..02a131f60ae 100644 --- a/tests/e2e/gateway/stage_mirror_ci_config.yml +++ b/tests/e2e/gateway/stage_mirror_ci_config.yml @@ -32,7 +32,10 @@ litellm_settings: - host: 127.0.0.1 port: 6379 ssl: true - callbacks: ["arize_phoenix", "datadog", "smtp_email", "prometheus", "otel"] + callbacks: ["arize_phoenix", "datadog", "smtp_email", "prometheus", "otel", "websearch_interception"] + websearch_interception_params: + enabled_providers: ["bedrock"] + search_tool_name: e2e-search require_auth_for_metrics_endpoint: false router_settings: @@ -40,6 +43,8 @@ router_settings: num_retries: 3 allowed_fails: 5 cooldown_time: 30 + model_group_alias: + e2e-alias-rl-alias: e2e-alias-rl-target model_list: - model_name: gpt-5.5 @@ -60,11 +65,22 @@ model_list: litellm_params: model: gemini/gemini-2.5-flash api_key: os.environ/GEMINI_API_KEY + - model_name: e2e-alias-rl-target + litellm_params: + model: anthropic/claude-haiku-4-5 + api_key: os.environ/ANTHROPIC_API_KEY + default_api_key_rpm_limit: 3 - model_name: openai-text-embedding-3-small litellm_params: model: openai/text-embedding-3-small api_key: os.environ/OPENAI_API_KEY +search_tools: + - search_tool_name: e2e-search + litellm_params: + search_provider: perplexity + api_key: os.environ/PERPLEXITY_API_KEY + files_settings: - custom_llm_provider: openai api_key: os.environ/OPENAI_API_KEY diff --git a/tests/e2e/guardrails/guardrails_client.py b/tests/e2e/guardrails/guardrails_client.py index 007e9392616..60f875ccb7d 100644 --- a/tests/e2e/guardrails/guardrails_client.py +++ b/tests/e2e/guardrails/guardrails_client.py @@ -291,6 +291,7 @@ class GuardrailsClient: text: str, *, guardrails: list[str] | None = None, + include_guardrail_response: bool | None = None, max_tokens: int = 16, tools: list[ChatTool] | None = None, ) -> Result[ChatResponse]: @@ -306,6 +307,7 @@ class GuardrailsClient: messages=[ChatMessage(role="user", content=text)], max_tokens=max_tokens, guardrails=guardrails, + include_guardrail_response=include_guardrail_response, tools=tools, ), ) diff --git a/tests/e2e/guardrails/test_guardrail_information_response_e2e.py b/tests/e2e/guardrails/test_guardrail_information_response_e2e.py new file mode 100644 index 00000000000..9701ea35819 --- /dev/null +++ b/tests/e2e/guardrails/test_guardrail_information_response_e2e.py @@ -0,0 +1,118 @@ +"""Live e2e: an opted-in chat response includes the guardrail execution details.""" + +from __future__ import annotations + +import time +from typing import Final + +import pytest + +from e2e_config import unique_marker +from e2e_http import unwrap +from guardrails_client import ( + BlockedWordBody, + ContentFilterParamsBody, + GuardrailsClient, +) +from lifecycle import ResourceManager +from models import ChatResponse, GuardrailInformationEntry + +pytestmark = pytest.mark.e2e + +GUARDRAIL_PROPAGATION_DEADLINE_SECONDS: Final = 40.0 +GUARDRAIL_PROPAGATION_POLL_INTERVAL_SECONDS: Final = 5.0 + + +def _register_content_filter(client: GuardrailsClient, resources: ResourceManager, *, name: str) -> None: + guardrail_id = client.register( + name, + ContentFilterParamsBody( + mode="pre_call", + default_on=False, + blocked_words=[BlockedWordBody(keyword=f"never-match-{unique_marker()}", action="MASK")], + ), + ) + resources.defer(lambda: client.delete_guardrail(guardrail_id)) + + +def _opted_in_entries( + client: GuardrailsClient, + key: str, + model: str, + name: str, +) -> tuple[ChatResponse, tuple[GuardrailInformationEntry, ...]]: + response = unwrap( + client.chat( + key, + model, + "Reply with the single word OK.", + guardrails=[name], + include_guardrail_response=True, + max_tokens=16, + ) + ) + entries = tuple(entry for entry in response.guardrail_information or () if entry.guardrail_name == name) + return response, entries + + +class TestGuardrailInformationResponse: + @pytest.mark.covers( + "guardrail.litellm_content_filter.pre_call.returns_guardrail_information", + exercised_on=["chat_completions"], + ) + def test_flag_returns_guardrail_information_for_the_guardrail_that_ran( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + name = f"e2e-guardrail-information-{unique_marker()}" + _register_content_filter(client, resources, name=name) + model = client.create_backend_model( + resources, + prefix="e2e-guardrail-info-backend", + backend="openai/gpt-4.1-mini", + api_key="os.environ/OPENAI_API_KEY", + ) + deadline = time.monotonic() + GUARDRAIL_PROPAGATION_DEADLINE_SECONDS + + while True: + response, entries = _opted_in_entries(client, scoped_key, model, name) + if len(entries) == 1: + entry = entries[0] + assert entry.guardrail_status == "success", ( + f"guardrail information should report a successful run, got {entry!r}; response: {response}" + ) + assert entry.duration is not None and entry.duration >= 0, ( + f"guardrail information should report a non-negative duration; response: {response}" + ) + return + if time.monotonic() >= deadline: + pytest.fail( + f"guardrail information did not report exactly one successful {name!r} entry within " + f"{GUARDRAIL_PROPAGATION_DEADLINE_SECONDS}s; response: {response}" + ) + time.sleep(GUARDRAIL_PROPAGATION_POLL_INTERVAL_SECONDS) + + def test_without_flag_response_has_no_guardrail_information( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + name = f"e2e-guardrail-information-default-{unique_marker()}" + _register_content_filter(client, resources, name=name) + model = client.create_backend_model( + resources, + prefix="e2e-guardrail-info-backend", + backend="openai/gpt-4.1-mini", + api_key="os.environ/OPENAI_API_KEY", + ) + + response = unwrap( + client.chat( + scoped_key, + model, + "Reply with the single word OK.", + guardrails=[name], + max_tokens=16, + ) + ) + + assert "guardrail_information" not in response.model_fields_set, ( + f"guardrail information must remain absent without include_guardrail_response, got {response}" + ) diff --git a/tests/e2e/llm_translation/realtime/realtime_client.py b/tests/e2e/llm_translation/realtime/realtime_client.py index 7c4a9cc4af9..7da7ceac4d3 100644 --- a/tests/e2e/llm_translation/realtime/realtime_client.py +++ b/tests/e2e/llm_translation/realtime/realtime_client.py @@ -286,7 +286,7 @@ def function_call_item(events: tuple[ReceivedEvent, ...]) -> OutputItem | None: # ---- session + client -------------------------------------------------- -def _as_text(message: str | bytes) -> str: +def as_text(message: str | bytes) -> str: return message.decode("utf-8") if isinstance(message, bytes) else message @@ -304,7 +304,7 @@ class RealtimeSession: collected: list[ReceivedEvent] = [] while time.monotonic() < deadline: try: - text = _as_text( + text = as_text( self.connection.recv(timeout=deadline - time.monotonic()) ) except TimeoutError: diff --git a/tests/e2e/llm_translation/realtime/test_realtime_e2e.py b/tests/e2e/llm_translation/realtime/test_realtime_e2e.py index f99fa8d86b3..d7870b26497 100644 --- a/tests/e2e/llm_translation/realtime/test_realtime_e2e.py +++ b/tests/e2e/llm_translation/realtime/test_realtime_e2e.py @@ -13,8 +13,9 @@ failure. See REALTIME_COVERAGE_MATRIX.md. """ import pytest +from lifecycle import ResourceManager +from models import LiteLLMParamsBody from pydantic import BaseModel - from realtime_client import ( PROVIDERS, ConversationItemCreate, @@ -27,14 +28,17 @@ from realtime_client import ( RealtimeProvider, ResponseCreate, ResponseDone, + ServerEnvelope, SessionConfig, SessionUpdate, + as_text, function_call_item, parse_last, realtime_model, transcript, user_message, ) +from websockets.exceptions import ConnectionClosedError pytestmark = pytest.mark.e2e @@ -147,3 +151,39 @@ def test_tool_call_round_trip( second = session.collect_until("response.done", timeout=60) assert "72" in transcript(second), "follow-up did not use the tool result" + + +_REFUSED_UPSTREAMS = ( + RealtimeProvider( + "azure-bad-key", + "azure-realtime-refused", + LiteLLMParamsBody( + model="azure/gpt-realtime", + api_key="invalid-e2e-key", + api_version="2025-08-28", + realtime_protocol="GA", + ), + ), +) + + +@pytest.mark.parametrize("provider", _REFUSED_UPSTREAMS, ids=[p.id for p in _REFUSED_UPSTREAMS]) +def test_upstream_handshake_refusal_is_an_error_event_and_policy_close( + client: RealtimeClient, + resources: ResourceManager, + scoped_key: str, + provider: RealtimeProvider, +) -> None: + model_name, model_id = client.provision(provider) + resources.defer(lambda: client.proxy.delete_model(model_id)) + + with client.connect(key=scoped_key, model=model_name) as session: + first = ServerEnvelope.model_validate_json( + as_text(session.connection.recv(timeout=15)) + ) + assert first.type == "error", first + with pytest.raises(ConnectionClosedError) as closed: + session.connection.recv(timeout=15) + + assert closed.value.rcvd is not None + assert closed.value.rcvd.code == 1008, closed.value diff --git a/tests/e2e/llm_translation/test_credential_messages_e2e.py b/tests/e2e/llm_translation/test_credential_messages_e2e.py index 52306ce3a7a..58e17f20bb8 100644 --- a/tests/e2e/llm_translation/test_credential_messages_e2e.py +++ b/tests/e2e/llm_translation/test_credential_messages_e2e.py @@ -49,5 +49,6 @@ class TestCredentialBackedMessages: extra_body=NO_PROXY_CACHE, ) assert message.role == "assistant", f"unexpected role: {message.role!r}" + assert message.usage.output_tokens > 0, f"/v1/messages billed no output tokens: {message.usage!r}" text = "".join(block.text for block in message.content if block.type == "text") assert text.strip(), f"/v1/messages returned no text: {message.content!r}" diff --git a/tests/e2e/logging/test_langsmith_batch_serialization_e2e.py b/tests/e2e/logging/test_langsmith_batch_serialization_e2e.py new file mode 100644 index 00000000000..874b2b6a045 --- /dev/null +++ b/tests/e2e/logging/test_langsmith_batch_serialization_e2e.py @@ -0,0 +1,125 @@ +"""Live e2e: a LangSmith batch whose metadata holds non JSON-native Python values +(datetime, Decimal) must reach the real LangSmith API instead of dying in +json.dumps and dropping the whole batch. Only the SDK path can put such values +into the batch (the proxy JSON-decodes request metadata), so this test drives +litellm.acompletion in-process against the real OpenAI API with a LangsmithLogger +injected per request, flushes the batch, and reads the run back by id through +LangSmith's own API. Nothing is mocked. +""" + +from __future__ import annotations + +import asyncio +import datetime +import decimal +import os +import time +import uuid +from dataclasses import dataclass +from typing import Final + +import pytest +from e2e_config import CHEAP_OPENAI_MODEL, POLL_INTERVAL, POLL_TIMEOUT, unique_marker +from e2e_http import Headers, Success, get_external +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +import litellm +from litellm.integrations.langsmith import LangsmithLogger + +pytestmark = pytest.mark.e2e + + +class LangsmithHeaders(Headers): + x_api_key: str = Field(serialization_alias="x-api-key") + + +class LangsmithRunExtra(BaseModel): + model_config = ConfigDict(extra="allow") + requester_metadata: dict[str, JsonValue] | None = None + + +class LangsmithRun(BaseModel): + id: str + session_name: str | None = None + extra: LangsmithRunExtra + + +@dataclass(frozen=True, slots=True) +class LangsmithCreds: + api_key: str + base_url: str + project: str + + +def load_langsmith_creds() -> LangsmithCreds: + api_key = os.getenv("LANGSMITH_API_KEY") + if not api_key: + pytest.fail("LangSmith e2e requires LANGSMITH_API_KEY; missing credentials is a hard failure, not a skip") + if os.getenv("LANGSMITH_MOCK"): + pytest.fail("LANGSMITH_MOCK is set; this e2e must hit the real LangSmith API") + return LangsmithCreds( + api_key=api_key, + base_url=(os.getenv("LANGSMITH_BASE_URL") or "https://api.smith.langchain.com").rstrip("/"), + project=os.getenv("LANGSMITH_PROJECT") or "litellm-e2e", + ) + + +def _fetch_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun | None: + result = get_external( + f"{creds.base_url}/runs/{run_id}", + response_type=LangsmithRun, + headers=LangsmithHeaders(x_api_key=creds.api_key), + ) + match result: + case Success(data=run): + return run + case _: + return None + + +def _poll_run(creds: LangsmithCreds, run_id: uuid.UUID) -> LangsmithRun: + deadline: Final = time.monotonic() + POLL_TIMEOUT + while time.monotonic() < deadline: + run = _fetch_run(creds, run_id) + if run is not None: + return run + time.sleep(POLL_INTERVAL) + pytest.fail(f"LangSmith run {run_id} never appeared within {POLL_TIMEOUT}s; the batch flush dropped it") + + +class TestLangsmithBatchSerialization: + @pytest.mark.asyncio + @pytest.mark.covers("logging.langsmith.success.serializes_non_native_metadata") + async def test_non_json_native_metadata_reaches_langsmith(self) -> None: + creds: Final = load_langsmith_creds() + logger: Final = LangsmithLogger( + langsmith_api_key=creds.api_key, langsmith_project=creds.project, langsmith_base_url=creds.base_url + ) + assert not logger.is_mock_mode, "LangsmithLogger initialised in mock mode; this e2e needs the real API" + marker: Final = unique_marker() + run_id: Final = uuid.uuid4() + created_at: Final = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + spend: Final = decimal.Decimal("0.0042") + response: Final = await litellm.acompletion( + model=f"openai/{CHEAP_OPENAI_MODEL}", + messages=[{"role": "user", "content": f"Reply with the single word ok ({marker})"}], + max_completion_tokens=5, + callbacks=[logger], + metadata={"run_id": str(run_id), "metadata": {"marker": marker, "created_at": created_at, "spend": spend}}, + ) + assert isinstance(response, litellm.ModelResponse) and response.id, ( + "a non-streaming completion must return a ModelResponse before the batch flush is meaningful" + ) + enqueue_deadline: Final = time.monotonic() + POLL_TIMEOUT + while time.monotonic() < enqueue_deadline and len(logger.log_queue) == 0: + await asyncio.sleep(0.5) + assert len(logger.log_queue) == 1, ( + f"the completion must be queued for the LangSmith batch, got {len(logger.log_queue)} queued entries" + ) + await logger.async_send_batch() + run: Final = _poll_run(creds, run_id) + requester_metadata: Final = run.extra.requester_metadata + assert requester_metadata is not None, "the run must carry the caller metadata under extra.requester_metadata" + assert requester_metadata["marker"] == marker + assert requester_metadata["created_at"] == str(created_at) + assert requester_metadata["spend"] == str(spend) diff --git a/tests/e2e/logging/test_otel_trace_e2e.py b/tests/e2e/logging/test_otel_trace_e2e.py index 9f08fa6c4e7..8d154ca0837 100644 --- a/tests/e2e/logging/test_otel_trace_e2e.py +++ b/tests/e2e/logging/test_otel_trace_e2e.py @@ -12,21 +12,24 @@ commit 1bd603d1ac). Both halves of the contract are asserted: the recorded state (the proxy reports the OTEL v2 logger active via /health/readiness/details) and the enforced behavior (the complete span tree at the destination, read back through the -destination's own query API - never proxy-side "export succeeded" logs). +destination's own query API - never proxy-side "export succeeded" logs). The +TLS coverage requires the stack to export OTLP over HTTPS with a certificate +signed by the CA in SSL_CERT_FILE, and treats a missing or plaintext endpoint +as a stack misconfiguration rather than skipping the test. """ from __future__ import annotations import time +from typing import Final import pytest -from pydantic import BaseModel, ConfigDict, ValidationError - -from e2e_config import CHEAP_ANTHROPIC_MODEL, CHEAP_OPENAI_MODEL, unique_marker +from e2e_config import CHEAP_ANTHROPIC_MODEL, CHEAP_OPENAI_MODEL, OTEL_EXPORTER_ENDPOINT, unique_marker from lifecycle import ResourceManager from logging_client import INVALID_UPSTREAM_API_KEY, LoggingClient, first_ok, readiness_details_body from models import LiteLLMParamsBody from otel_client import JaegerSpan, JaegerTrace, OtelReader +from pydantic import BaseModel, ConfigDict, ValidationError pytestmark = pytest.mark.e2e @@ -312,6 +315,35 @@ class TestOtelTraceCompleteness: ) _assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}") + @pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["chat_completions"]) + @pytest.mark.otel_tls + def test_otel_export_over_tls_with_internal_ca_reaches_destination( + self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager + ) -> None: + _assert_otel_destination_configured(client) + assert OTEL_EXPORTER_ENDPOINT.startswith("https://"), ( + "the stack must export OTLP over TLS signed by the CA in SSL_CERT_FILE " + "(E2E_OTEL_EXPORTER_ENDPOINT) for this test to prove anything; a " + "missing or plaintext value is a stack misconfiguration" + ) + + route: Final = "/chat/completions" + key: Final = client.key_with_alias(f"otel-trace-tls-{unique_marker()}", models=[MODEL]) + resources.defer(lambda: client.delete_key(key)) + + marker: Final = unique_marker() + outcome: Final = first_ok( + client, lambda: client.chat_raw(key, MODEL, f"reply with one word {marker}", max_tokens=16) + ) + assert outcome.call_id is not None, "success response must carry x-litellm-call-id" + + hits: Final = otel_reader.poll_traces_for_call( + call_id=outcome.call_id, + settled_names=_settled_names(route=route, genai_span=f"chat {MODEL}"), + settled_prefixes={DB_SPAN_PREFIX}, + ) + _assert_complete_trace(hits, route=route, genai_span=f"chat {MODEL}") + @pytest.mark.covers("logging.otel.success.exports_metric", exercised_on=["messages"]) def test_messages_exports_complete_trace( self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index 8470d318db8..7366695c0d1 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -25,6 +25,8 @@ from e2e_http import ( unwrap, ) from models import ( + AuditLogPage, + AuditLogParams, ChatBody, ChatMessage, ConnectionTestBody, @@ -35,6 +37,7 @@ from models import ( CustomerResponse, KeyBlockBody, KeyDeleteBody, + KeyDeleteByAliasBody, KeyGenerateBody, KeyGenerateResponse, KeyInfoParams, @@ -159,6 +162,31 @@ class ManagementClient: def update_key_models(self, key: str, models: list[str]) -> None: _ = unwrap(self.update_key(KeyUpdateBody(key=key, models=models))) + def delete_key_by_alias(self, key_alias: str) -> None: + _ = unwrap( + self.proxy.transport.post( + "/key/delete", + headers=self.proxy.management_headers(), + json=KeyDeleteByAliasBody(key_aliases=[key_alias]), + response_type=NoBody, + ) + ) + + def key_deleted_audit_logs(self, token_hash: str) -> AuditLogPage: + return unwrap( + self.proxy.transport.get( + "/audit", + headers=self.proxy.management_headers(), + params=AuditLogParams( + object_id=token_hash, + action="deleted", + table_name="LiteLLM_VerificationToken", + page_size=100, + ), + response_type=AuditLogPage, + ) + ) + def key_info_as(self, key: str, *, caller_key: str | None = None) -> Result[KeyInfoResponse]: return self.proxy.transport.get( "/key/info", diff --git a/tests/e2e/management/test_management_e2e.py b/tests/e2e/management/test_management_e2e.py index 476165b715d..da0fc37aff8 100644 --- a/tests/e2e/management/test_management_e2e.py +++ b/tests/e2e/management/test_management_e2e.py @@ -16,8 +16,8 @@ from typing import Final import pytest -from e2e_config import UI_PASSWORD, UI_USERNAME, unique_marker -from e2e_http import StreamingResponse, Success +from e2e_config import POLL_INTERVAL, POLL_TIMEOUT, UI_PASSWORD, UI_USERNAME, unique_marker +from e2e_http import StreamingResponse, Success, unwrap from lifecycle import ResourceManager from management_client import ( DASHBOARD_SESSION_TEAM_ID, @@ -26,7 +26,9 @@ from management_client import ( ManagementClient, ) from models import ( + AuditLogPage, KeyGenerateBody, + KeyGenerateResponse, KeyUpdateBody, LiteLLMParamsBody, ModelInfoEntry, @@ -40,6 +42,7 @@ from models import ( UserNewBody, UserUpdateBody, ) +from proxy_client import Converged, await_converged pytestmark = pytest.mark.e2e @@ -801,3 +804,128 @@ class TestCustomer: assert info.user_id == customer, ( f"/customer/info did not report the created end-user; got {info.user_id!r}" ) + + +def _await_deleted_audit_rows(client: ManagementClient, token_hash: str) -> AuditLogPage: + outcome = await_converged( + lambda: client.key_deleted_audit_logs(token_hash), + converged=lambda page: page.total >= 1, + timeout=POLL_TIMEOUT, + interval=POLL_INTERVAL, + now=time.monotonic, + sleep=time.sleep, + ) + return outcome.result if isinstance(outcome, Converged) else outcome.last_result + + +def _assert_single_deleted_row(page: AuditLogPage, token_hash: str) -> None: + assert page.total == 1, page + row = page.audit_logs[0] + assert row.action == "deleted", row + assert row.table_name == "LiteLLM_VerificationToken", row + assert row.object_id == token_hash, row + assert row.changed_by, row + + +def _assert_key_deleted(client: ManagementClient, key: str) -> None: + def gone() -> bool | None: + match client.key_info_as(key): + case Success(data=response): + return True if response.info.status == "deleted" else None + case _: + return True + + _ = _poll( + client, + gone, + "/key/info never reported status 'deleted' for a key whose deletion returned", + ) + + +def _token_of(created: KeyGenerateResponse) -> str: + assert created.token is not None, created + return created.token + + +def _generate_response( + client: ManagementClient, resources: ResourceManager, body: KeyGenerateBody +) -> KeyGenerateResponse: + created = unwrap(client.generate_key(body)) + resources.defer(lambda: client.delete_key_strict(created.key, missing_ok=True)) + return created + + +class TestKeyDeletionAuditLog: + @pytest.mark.covers("mgmt.key.delete.audit_logged") + def test_key_delete_by_key_writes_audit_row( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + created = _generate_response(client, resources, KeyGenerateBody(key_alias=f"e2e-audit-{unique_marker()}")) + token = _token_of(created) + + client.delete_key_strict(created.key) + + _assert_key_deleted(client, created.key) + _assert_single_deleted_row(_await_deleted_audit_rows(client, token), token) + + @pytest.mark.covers("mgmt.key.delete.audit_logged") + def test_key_delete_by_alias_writes_audit_row( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + alias = f"e2e-audit-{unique_marker()}" + created = _generate_response(client, resources, KeyGenerateBody(key_alias=alias)) + token = _token_of(created) + + client.delete_key_by_alias(alias) + + _assert_key_deleted(client, created.key) + _assert_single_deleted_row(_await_deleted_audit_rows(client, token), token) + + @pytest.mark.covers("mgmt.team.member_delete.audit_logs_keys") + def test_team_member_delete_writes_audit_row_for_member_keys( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + team_id = _create_team(client, resources, f"e2e-audit-team-{unique_marker()}", []) + user_id = _create_user( + client, + resources, + UserNewBody(user_email=f"e2e-audit-{unique_marker()}@example.com", user_role="internal_user"), + ) + client.add_team_member(team_id, user_id) + created = _generate_response(client, resources, KeyGenerateBody(user_id=user_id, team_id=team_id)) + token = _token_of(created) + + client.delete_team_member(team_id, user_id) + + _assert_key_deleted(client, created.key) + _assert_single_deleted_row(_await_deleted_audit_rows(client, token), token) + + @pytest.mark.covers("mgmt.team.delete.audit_logs_keys") + def test_team_delete_writes_audit_row_for_team_keys( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + team_id = _create_team(client, resources, f"e2e-audit-team-{unique_marker()}", []) + created = _generate_response(client, resources, KeyGenerateBody(team_id=team_id)) + token = _token_of(created) + + client.delete_team(team_id) + + _assert_key_deleted(client, created.key) + _assert_single_deleted_row(_await_deleted_audit_rows(client, token), token) + + @pytest.mark.covers("mgmt.user.delete.audit_logs_keys") + def test_user_delete_writes_audit_row_for_user_keys( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + user_id = _create_user( + client, + resources, + UserNewBody(user_email=f"e2e-audit-{unique_marker()}@example.com", user_role="internal_user"), + ) + created = _generate_response(client, resources, KeyGenerateBody(user_id=user_id)) + token = _token_of(created) + + client.delete_user_strict(user_id) + + _assert_key_deleted(client, created.key) + _assert_single_deleted_row(_await_deleted_audit_rows(client, token), token) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 5278dbd287a..bc81d1015ba 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -124,6 +124,32 @@ class KeyDeleteBody(BaseModel): keys: list[str] +class KeyDeleteByAliasBody(BaseModel): + key_aliases: list[str] + + +class AuditLogParams(BaseModel): + object_id: str + action: str + table_name: str + page_size: int + + +class AuditLogEntry(BaseModel): + id: str + changed_by: str | None = None + changed_by_api_key: str | None = None + action: str + table_name: str + object_id: str + before_value: object | None = None + + +class AuditLogPage(BaseModel): + audit_logs: list[AuditLogEntry] + total: int + + class KeyInfoParams(BaseModel): key: str @@ -308,6 +334,7 @@ class ChatBody(BaseModel): tools: Sequence[ChatTool | McpChatTool] | None = None tool_choice: str | None = None guardrails: list[str] | None = None + include_guardrail_response: bool | None = None response_format: dict[str, object] | None = None chat_template_kwargs: dict[str, bool] | None = None cache: dict[str, bool] | None = {"no-cache": True} @@ -422,6 +449,14 @@ class Usage(BaseModel): completion_tokens_details: CompletionTokensDetails | None = None +class GuardrailInformationEntry(BaseModel): + guardrail_name: str + guardrail_status: str + guardrail_mode: object | None = None + guardrail_response: object | None = None + duration: float | None = None + + class ChatResponse(BaseModel): id: str | None = None object: str | None = None @@ -429,6 +464,7 @@ class ChatResponse(BaseModel): choices: list[ChatChoice] = [] usage: Usage | None = None service_tier: str | None = None + guardrail_information: list[GuardrailInformationEntry] | None = None # ---------- anthropic /v1/messages + count_tokens ---------- @@ -916,6 +952,7 @@ class SpendLogRow(BaseModel): cache_hit: str | None = None call_type: str | None = None custom_llm_provider: str | None = None + model_id: str | None = None team_id: str | None = None user: str | None = None end_user: str | None = None @@ -923,6 +960,7 @@ class SpendLogRow(BaseModel): completion_tokens: int | None = None total_tokens: int | None = None request_tags: list[str] | None = None + session_id: str | None = None metadata: SpendLogMetadata | None = None proxy_server_request: JsonValue = None response: JsonValue = None @@ -959,6 +997,15 @@ class SpendLogsPageParams(BaseModel): api_key: str | None = None +class SessionSpendLogsParams(BaseModel): + """Query for /spend/logs/session/ui, the session view the Admin UI logs page + opens: every row whose session_id equals the given one, newest first.""" + + session_id: str + page: int = 1 + page_size: int = 100 + + class SpendLogsPage(BaseModel): data: list[SpendLogRow] = [] total: int diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py index f1981e0aa5e..66df1c4c106 100644 --- a/tests/e2e/proxy_client.py +++ b/tests/e2e/proxy_client.py @@ -87,6 +87,7 @@ from models import ( RouterSettingsResponse, SearchToolCreateBody, SearchToolCreateResponse, + SessionSpendLogsParams, SpendLogRow, SpendLogs, SpendLogsPage, @@ -1004,19 +1005,26 @@ class ProxyClient: response_type=CountTokensResponse, ) - def messages(self, key: str, body: AnthropicMessagesBody) -> Result[AnthropicMessagesResponse]: + def messages( + self, key: str, body: AnthropicMessagesBody, *, session_id: str | None = None + ) -> Result[AnthropicMessagesResponse]: """POST /v1/messages (Anthropic-native). The response is either the Anthropic-shape passthrough (`content`) or the OpenAI-normalized shape - (`choices`); AnthropicMessagesResponse models both.""" + (`choices`); AnthropicMessagesResponse models both. `session_id` goes out + as the `x-litellm-session-id` header, the way Claude Code sends it through + ANTHROPIC_CUSTOM_HEADERS, so every spend row the call produces shares it.""" return self.transport.post( "/v1/messages", - headers=self._anthropic_headers(key), + headers=self._anthropic_headers(key, session_id=session_id), json=body, response_type=AnthropicMessagesResponse, ) - def _anthropic_headers(self, key: str) -> AnthropicHeaders: - return AnthropicHeaders(authorization=self.transport.bearer(key).authorization) + def _anthropic_headers(self, key: str, *, session_id: str | None = None) -> AnthropicHeaders: + return AnthropicHeaders( + authorization=self.transport.bearer(key).authorization, + x_litellm_session_id=session_id, + ) # ---- spend read-back ------------------------------------------------ @@ -1060,6 +1068,27 @@ class ProxyClient: ) -> list[SpendLogRow]: return self._poll(lambda: self.spend_logs(SpendLogsParams(api_key=key)), min_rows, predicate) + def session_spend_logs(self, session_id: str) -> list[SpendLogRow]: + """GET /spend/logs/session/ui, the per-session view the Admin UI logs page + opens when a session id is clicked.""" + return unwrap( + self.transport.get( + "/spend/logs/session/ui", + headers=self.management_headers(), + params=SessionSpendLogsParams(session_id=session_id), + response_type=SpendLogsPage, + ) + ).data + + def poll_logs_for_session( + self, + session_id: str, + *, + min_rows: int = 1, + predicate: RowsPredicate | None = None, + ) -> list[SpendLogRow]: + return self._poll(lambda: self.session_spend_logs(session_id), min_rows, predicate) + def poll_logs_for_request_id( self, request_id: str, diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini index 6f9f57d333e..a77459d3683 100644 --- a/tests/e2e/pytest.ini +++ b/tests/e2e/pytest.ini @@ -15,3 +15,4 @@ markers = mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless E2E_MCP_OAUTH_LIVE is set provider_edge_host: routes provider traffic through the pytest host's edge in every fixture mode, so the gateway must reach the pytest host; deselected unless E2E_PROVIDER_EDGE_HOST_REACHABLE is set otel_v2: needs a proxy running with LITELLM_OTEL_V2=true; deselected unless E2E_OTEL_V2 is set + otel_tls: needs a stack whose gateway exports OTLP over TLS signed by the CA in SSL_CERT_FILE; deselected unless E2E_OTEL_EXPORTER_ENDPOINT is set diff --git a/tests/e2e/quota_management/ratelimit/test_model_group_alias_rate_limit_e2e.py b/tests/e2e/quota_management/ratelimit/test_model_group_alias_rate_limit_e2e.py new file mode 100644 index 00000000000..1c3fff47b78 --- /dev/null +++ b/tests/e2e/quota_management/ratelimit/test_model_group_alias_rate_limit_e2e.py @@ -0,0 +1,85 @@ +"""Live e2e: a model group alias must share its per-key deployment rate-limit +bucket with the model group it resolves to. + +Covers quota_management.ratelimit.model_group_alias.shares_bucket: the proxy +config declares `e2e-alias-rl-target` (a cheap Anthropic deployment) with +`default_api_key_rpm_limit: 3` and `router_settings.model_group_alias` mapping +`e2e-alias-rl-alias` -> `e2e-alias-rl-target`. Both spellings must draw on +one per-key rpm bucket, so a key that exhausts the limit on one spelling is +blocked on the other spelling inside the same window; each test in this file +exhausts the budget on one name and asserts the other name 429s. + +All calls of one test must land inside a single window +(LITELLM_RATE_LIMIT_WINDOW_SIZE, 60s default), which real chat latency +comfortably allows. +""" + +from __future__ import annotations + +import time + +import pytest +from e2e_config import unique_marker +from e2e_http import StreamingResponse, require_successful_call +from quota_client import QuotaClient + +pytestmark = pytest.mark.e2e + +MODEL_GROUP = "e2e-alias-rl-target" +MODEL_ALIAS = "e2e-alias-rl-alias" +RPM_LIMIT = 3 +WINDOW_SECONDS = 60 +LAST_CALL_LATENCY_MARGIN_SECONDS = 10 + + +def _chat(client: QuotaClient, key: str, model: str) -> StreamingResponse: + return client.chat(key, model, f"reply with one word {unique_marker()}") + + +def _exhaust_rpm(client: QuotaClient, key: str, model: str) -> float: + """Send RPM_LIMIT successful calls on `model`, opening the rate-limit + window; returns the send timestamp of the first call as a lower bound on + the window start. A fresh key may briefly 401 until the data plane's auth + cache picks it up, so retry on 401 to a deadline; a 401 never reaches the + rate limiter.""" + deadline = time.monotonic() + client.proxy.poll_timeout + first_sent_at: float | None = None + sent = 0 + while sent < RPM_LIMIT: + if first_sent_at is None: + first_sent_at = time.monotonic() + outcome = _chat(client, key, model) + if outcome.status_code == 401 and time.monotonic() < deadline: + time.sleep(client.proxy.poll_interval) + continue + require_successful_call(outcome) + sent += 1 + assert first_sent_at is not None + return first_sent_at + + +def _assert_blocked_inside_window( + client: QuotaClient, key: str, model: str, window_opened_at: float +) -> StreamingResponse: + assert time.monotonic() < window_opened_at + WINDOW_SECONDS - LAST_CALL_LATENCY_MARGIN_SECONDS, ( + f"the {RPM_LIMIT} exhaust calls took too long; the follow-up call could land in the " + "next window and mask a shared-bucket regression" + ) + outcome = _chat(client, key, model) + assert outcome.status_code == 429, ( + f"{model} must share its rpm bucket with the model group/alias that was already " + f"exhausted, expected a 429 but got {outcome.status_code}: {outcome.body[:300]}" + ) + return outcome + + +class TestModelGroupAliasRateLimit: + @pytest.mark.covers("quota_management.ratelimit.model_group_alias.shares_bucket") + def test_alias_shares_rpm_bucket_with_model_group(self, client: QuotaClient, scoped_key: str) -> None: + opened_at = _exhaust_rpm(client, scoped_key, MODEL_GROUP) + _assert_blocked_inside_window(client, scoped_key, MODEL_ALIAS, opened_at) + + @pytest.mark.covers("quota_management.ratelimit.model_group_alias.shares_bucket") + def test_model_group_shares_rpm_bucket_with_alias(self, client: QuotaClient, scoped_key: str) -> None: + opened_at = _exhaust_rpm(client, scoped_key, MODEL_ALIAS) + _assert_blocked_inside_window(client, scoped_key, MODEL_GROUP, opened_at) diff --git a/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py b/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py index b7f59fe5f89..8b63b063e14 100644 --- a/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py +++ b/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py @@ -12,9 +12,10 @@ helpers from one place. from __future__ import annotations import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from types import MappingProxyType from typing import Final from e2e_config import unique_marker @@ -48,6 +49,7 @@ from models import ( SpendLogsPageParams, SpendTagsResponse, TagSpend, + TeamInfoParams, UserDeleteBody, UserDeleteResponse, UserNewBody, @@ -57,6 +59,8 @@ from models import ( from proxy_client import Converged, ProxyClient, await_converged from pydantic import BaseModel, Field +METRICS_PATH: Final = "/metrics/" + __all__ = [ "BatchCreateBody", "CallbackLogMetadata", @@ -189,6 +193,7 @@ class DailyActivityKeyMetadata(BaseModel): class DailyActivityKeyMetrics(BaseModel): api_requests: int = 0 + spend: float = 0.0 class DailyActivityKeyBreakdown(BaseModel): @@ -209,6 +214,14 @@ class DailyActivityResponse(BaseModel): results: list[DailyActivityRow] = [] +class TeamInfoSpend(BaseModel): + spend: float | None = None + + +class TeamInfoSpendResponse(BaseModel): + team_info: TeamInfoSpend + + def _chat_body( model: str, content: str, @@ -334,6 +347,42 @@ class SpendClient: time.sleep(self.proxy.poll_interval) return spend + def team_spend(self, team_id: str) -> float: + return ( + unwrap( + self.proxy.transport.get( + "/team/info", + headers=self.proxy.transport.master, + params=TeamInfoParams(team_id=team_id), + response_type=TeamInfoSpendResponse, + ) + ).team_info.spend + or 0.0 + ) + + def poll_team_spend(self, team_id: str, *, minimum: float = 0.0) -> float: + outcome: Final = await_converged( + lambda: self.team_spend(team_id), + converged=lambda spend: spend > minimum, + timeout=self.proxy.poll_timeout, + interval=self.proxy.poll_interval, + now=time.monotonic, + sleep=time.sleep, + ) + return outcome.result if isinstance(outcome, Converged) else outcome.last_result + + def scrape_metrics(self) -> Mapping[str, ProbeResult]: + """GET /metrics/ on every replica in PROXY_REPLICA_URLS, keyed by replica. The + counter is per pod, so the union of the replicas is the fleet's exposition; the + trailing slash is the mounted app's own path, since bare /metrics answers a 307 + whose Location drops the port behind a Host-rewriting balancer.""" + return MappingProxyType( + { + replica: transport.probe(METRICS_PATH, params=NoBody()) + for replica, transport in self.proxy.replicas.items() + } + ) + def spend_logs_page( self, *, api_key: str | None, page: int, page_size: int ) -> SpendLogsPage: @@ -500,9 +549,21 @@ class SpendClient: return self.proxy.transport.probe("/health", params=HealthParams(model=model)) def daily_activity_for_key(self, token: str, *, start: datetime, end: datetime) -> DailyActivityKeyBreakdown | None: + return self._key_breakdown("/user/daily/activity", token, start=start, end=end) + + def usage_export_row_for_key( + self, token: str, *, start: datetime, end: datetime + ) -> DailyActivityKeyBreakdown | None: + """The key's row on /user/daily/activity/aggregated, the response the + dashboard's Export Usage Data CSV serializes.""" + return self._key_breakdown("/user/daily/activity/aggregated", token, start=start, end=end) + + def _key_breakdown( + self, route: str, token: str, *, start: datetime, end: datetime + ) -> DailyActivityKeyBreakdown | None: response: Final = unwrap( self.proxy.transport.get( - "/user/daily/activity", + route, headers=self.proxy.transport.master, params=DailyActivityParams( start_date=start.strftime("%Y-%m-%d"), @@ -519,9 +580,21 @@ class SpendClient: def poll_daily_activity_for_key( self, token: str, *, start: datetime, end: datetime, min_requests: int + ) -> DailyActivityKeyBreakdown | None: + return self._poll_key_breakdown(lambda: self.daily_activity_for_key(token, start=start, end=end), min_requests) + + def poll_usage_export_row_for_key( + self, token: str, *, start: datetime, end: datetime, min_requests: int + ) -> DailyActivityKeyBreakdown | None: + return self._poll_key_breakdown( + lambda: self.usage_export_row_for_key(token, start=start, end=end), min_requests + ) + + def _poll_key_breakdown( + self, fetch: Callable[[], DailyActivityKeyBreakdown | None], min_requests: int ) -> DailyActivityKeyBreakdown | None: outcome: Final = await_converged( - lambda: self.daily_activity_for_key(token, start=start, end=end), + fetch, converged=lambda found: found is not None and found.metrics.api_requests >= min_requests, timeout=self.proxy.poll_timeout, interval=self.proxy.poll_interval, diff --git a/tests/e2e/quota_management/spend_tracking/test_spend_surface_consistency_e2e.py b/tests/e2e/quota_management/spend_tracking/test_spend_surface_consistency_e2e.py new file mode 100644 index 00000000000..9033b9d75c4 --- /dev/null +++ b/tests/e2e/quota_management/spend_tracking/test_spend_surface_consistency_e2e.py @@ -0,0 +1,161 @@ +"""One priced request must land the same response_cost on every spend surface. + +A customer reconciles the bill from whichever surface they look at: the spend +log row, the key's and the team's rolled-up spend on /key/info and /team/info, +the usage page's Export Usage Data CSV (the dashboard serializes the +/user/daily/activity/aggregated rows it already holds; there is no server-side +CSV endpoint), and the litellm_spend_metric counter Prometheus scrapes. Each is +written by a different writer (the spend log insert, the key and team rollups in +db_spend_update_writer, the daily spend tables, the Prometheus success callback), +so one of them can drift without the others noticing: the cause of the +key-versus-log mismatch in LIT-3620 and the export-versus-console mismatch in +LIT-5045. The deployment carries its own per-token rates, so the expected cost +is computed from the returned usage rather than read off any one surface, and +every surface is held to that number. + +/metrics is per pod, so every replica the stack exports (PROXY_REPLICA_URLS) is +scraped directly and the samples merged; a stack that exports only its balancer +is scraped there until the pod that served the call answers. The request itself +is sent once. +""" + +from __future__ import annotations + +import time +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from itertools import groupby +from math import isclose +from types import MappingProxyType +from typing import Final + +import pytest +from e2e_config import provider_edge_base, unique_marker +from e2e_http import ProbeResult +from lifecycle import ResourceManager +from models import ChatBody, ChatMessage, KeyGenerateBody, LiteLLMParamsBody, TeamNewBody +from prometheus_client.parser import text_string_to_metric_families +from proxy_client import Converged, await_converged +from spend_e2e_client import SpendClient, unwrap +from spend_reconciliation import INPUT_RATE, OUTPUT_RATE + +pytestmark = pytest.mark.e2e + +SPEND_METRIC: Final = "litellm_spend_metric_total" +KEY_HASH_LABEL: Final = "hashed_api_key" +TEAM_LABEL: Final = "team" + +SeriesLabels = tuple[tuple[str, str], ...] + + +def _spend_series_for_key(scrapes: Mapping[str, ProbeResult], token: str) -> Mapping[SeriesLabels, float]: + samples: Final = sorted( + (tuple(sorted(sample.labels.items())), sample.value) + for scrape in scrapes.values() + if scrape.status_code == 200 + for family in text_string_to_metric_families(scrape.body) + for sample in family.samples + if sample.name == SPEND_METRIC and sample.labels.get(KEY_HASH_LABEL) == token + ) + return MappingProxyType( + {labels: sum(value for _, value in group) for labels, group in groupby(samples, key=lambda sample: sample[0])} + ) + + +def _poll_spend_series_for_key(client: SpendClient, token: str) -> Mapping[SeriesLabels, float]: + outcome: Final = await_converged( + client.scrape_metrics, + converged=lambda scrapes: bool(_spend_series_for_key(scrapes, token)), + timeout=client.proxy.poll_timeout, + interval=client.proxy.poll_interval, + now=time.monotonic, + sleep=time.sleep, + ) + scrapes: Final = outcome.result if isinstance(outcome, Converged) else outcome.last_result + assert _spend_series_for_key(scrapes, token), ( + f"{SPEND_METRIC} never exposed a series for {KEY_HASH_LABEL}={token} on any replica; " + f"last scrape status per replica: {({replica: scrape.status_code for replica, scrape in scrapes.items()})}" + ) + return _spend_series_for_key(scrapes, token) + + +def _same_spend(actual: float | None, expected: float) -> bool: + return actual is not None and isclose(actual, expected, rel_tol=1e-6, abs_tol=1e-9) + + +class TestSpendSurfaceConsistency: + @pytest.mark.replayable + @pytest.mark.covers("quota_management.spend_tracking.surface_consistency.matches_every_surface") + def test_one_request_lands_the_same_spend_on_every_surface( + self, client: SpendClient, resources: ResourceManager + ) -> None: + started: Final = datetime.now(timezone.utc) + marker: Final = unique_marker() + base: Final = provider_edge_base("openai") + model: Final = f"e2e-spend-surfaces-{marker}" + model_id: Final = client.proxy.create_model( + model, + LiteLLMParamsBody( + model="openai/gpt-5.6-luna", + api_key="os.environ/OPENAI_API_KEY", + api_base=None if base is None else f"{base}/v1", + input_cost_per_token=INPUT_RATE, + output_cost_per_token=OUTPUT_RATE, + ), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + team_id: Final = client.proxy.create_team(TeamNewBody(team_alias=f"e2e-spend-surfaces-{marker}")) + resources.defer(lambda: client.proxy.delete_team(team_id)) + record: Final = client.generate_key_record( + KeyGenerateBody(team_id=team_id, models=[model], key_alias=f"e2e-spend-surfaces-{marker}") + ) + resources.defer(lambda: client.proxy.delete_key(record.key)) + assert record.token, "/key/generate answered without the key's token hash" + token: Final = record.token + + response: Final = unwrap( + client.proxy.chat( + record.key, + ChatBody( + model=model, + messages=[ChatMessage(role="user", content=f"Reply with one word. {marker}")], + max_completion_tokens=128, + ), + ) + ) + usage: Final = response.usage + assert response.id, "successful response must have an ID" + assert usage is not None and usage.prompt_tokens and usage.completion_tokens, f"no billable usage: {usage}" + expected: Final = usage.prompt_tokens * INPUT_RATE + usage.completion_tokens * OUTPUT_RATE + + rows: Final = client.proxy.poll_logs_for_request_id(response.id) + assert len(rows) == 1, f"expected one spend row for {response.id}, saw {len(rows)}: {rows}" + row: Final = rows[0] + assert row.api_key == token, f"spend row keyed by {row.api_key}, not the key's token hash {token}" + assert row.team_id == team_id, f"spend row attributed to team {row.team_id}, not {team_id}" + assert row.status == "success", f"spend row status {row.status}" + + key_spend: Final = client.poll_key_spend(record.key, minimum=expected * 0.999999) + team_spend: Final = client.poll_team_spend(team_id, minimum=expected * 0.999999) + export_row: Final = client.poll_usage_export_row_for_key( + token, start=started - timedelta(days=1), end=datetime.now(timezone.utc), min_requests=1 + ) + assert export_row is not None, f"/user/daily/activity/aggregated never listed key {token} under api_keys" + series: Final = _poll_spend_series_for_key(client, token) + off_team: Final = tuple(labels for labels in series if dict(labels).get(TEAM_LABEL) != team_id) + assert not off_team, f"{SPEND_METRIC} series for the key carry a team other than {team_id}: {off_team}" + + observed: Final = MappingProxyType( + { + "/spend/logs row": row.spend, + "/key/info spend": key_spend, + "/team/info spend": team_spend, + "usage export row (/user/daily/activity/aggregated)": export_row.metrics.spend, + SPEND_METRIC: sum(series.values()), + } + ) + drifted: Final = tuple(surface for surface, spend in observed.items() if not _same_spend(spend, expected)) + assert not drifted, ( + f"response_cost {expected} (usage {usage.prompt_tokens}x{INPUT_RATE} + " + f"{usage.completion_tokens}x{OUTPUT_RATE}) drifted on {drifted}; every surface: {dict(observed)}" + ) diff --git a/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py b/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py index 8a91e53e7d7..6ca6f8cee55 100644 --- a/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py +++ b/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py @@ -21,9 +21,9 @@ from math import isclose from typing import Final import pytest -from e2e_http import Success +from e2e_http import RateLimitedError, Success from lifecycle import ResourceManager -from models import LiteLLMParamsBody, SpendLogs, SpendLogsParams +from models import KeyGenerateBody, LiteLLMParamsBody, SpendLogs, SpendLogsParams from spend_e2e_client import SpendClient, SpendLogRow, is_ok, unique_marker, unwrap pytestmark = pytest.mark.e2e @@ -43,6 +43,7 @@ def _summarize(rows: list[SpendLogRow]) -> list[dict[str, object]]: "cache_hit", "call_type", "custom_llm_provider", + "model_id", "prompt_tokens", "completion_tokens", "total_tokens", @@ -498,6 +499,47 @@ def test_failure_call_writes_failure_status_row( assert (failure_row.spend or 0) == 0.0, "failed call must not be charged" +@pytest.mark.covers("quota_management.spend_tracking.failure.attributes_provider") +def test_pre_call_rejection_row_attributes_provider_and_model_id( + client: SpendClient, resources: ResourceManager +) -> None: + """A request the proxy rejects before the router picks a deployment (here the + key's rpm limit, a pre_call_hook 429) never reaches the code that stamps the + deployment onto the log. The failure row must still carry the provider and + model_id of the model group's only deployment, so per-provider failure reports + can count it.""" + model = f"e2e-spend-precall-{unique_marker()}" + model_id = client.proxy.create_model( + model, LiteLLMParamsBody(model="openai/gpt-5.5", api_key="os.environ/OPENAI_API_KEY") + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + key = client.proxy.generate_key(KeyGenerateBody(models=[model], rpm_limit=1)) + resources.defer(lambda: client.proxy.delete_key(key)) + + unwrap(client.chat(key, model, f"reply with one word {unique_marker()}", max_tokens=8)) + rejected = client.chat(key, model, f"over the rpm limit {unique_marker()}", max_tokens=8) + assert isinstance(rejected, RateLimitedError), ( + f"the second call on an rpm_limit=1 key must be rejected with 429 before routing, got {rejected}" + ) + + rows = client.poll_logs_for_key( + key, + min_rows=2, + predicate=lambda rs: {r.status for r in rs} >= {"success", "failure"}, + ) + success_row = _require_row(rows, lambda r: r.status == "success", "for the served call") + failure_row = _require_row(rows, lambda r: r.status == "failure", "for the rate-limited call") + + assert failure_row.custom_llm_provider == success_row.custom_llm_provider, ( + f"rejected call lost its provider: failure row {failure_row.custom_llm_provider!r} vs " + f"served row {success_row.custom_llm_provider!r}; {_summarize(rows)}" + ) + assert failure_row.model_id == model_id, ( + f"rejected call lost its deployment: failure row model_id {failure_row.model_id!r} vs " + f"registered {model_id!r}; {_summarize(rows)}" + ) + + @pytest.mark.covers("quota_management.spend_tracking.spend_calculate.returns_cost") def test_spend_calculate_returns_nonzero_cost(client: SpendClient) -> None: cost = client.calculate_spend( diff --git a/tests/e2e/quota_management/spend_tracking/test_websearch_interception_session_e2e.py b/tests/e2e/quota_management/spend_tracking/test_websearch_interception_session_e2e.py new file mode 100644 index 00000000000..89d0beec414 --- /dev/null +++ b/tests/e2e/quota_management/spend_tracking/test_websearch_interception_session_e2e.py @@ -0,0 +1,102 @@ +"""Intercepted web searches are billed under the LLM request's session. + +The websearch_interception callback turns an Anthropic ``web_search`` server tool +into a ``litellm.asearch`` call against a configured search tool, so each search is +its own spend row (call_type ``asearch``) next to the ``anthropic_messages`` row for +the turn that asked for it. Claude Code and the Admin UI group spend by +``session_id``, so the search row has to carry the same session as the turn that +triggered it; before the fix it landed under a session of its own and the session +view under-counted both requests and spend (LIT-8063). + +Needs a proxy booted with the callback and a real search backend, which +``gateway/stage_mirror_ci_config.yml`` carries as the ``e2e-search`` Perplexity tool. +""" + +from typing import Final + +import pytest +from e2e_config import unique_marker +from e2e_http import unwrap +from lifecycle import ResourceManager +from models import ( + AnthropicMessagesBody, + AnthropicWebSearchTool, + ChatMessage, + LiteLLMParamsBody, + SpendLogRow, +) +from proxy_client import ProxyClient + +pytestmark = pytest.mark.e2e + +BEDROCK_INVOKE_BACKEND: Final = "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0" +SEARCH_CALL_TYPE: Final = "asearch" + + +def _has_search_row(rows: list[SpendLogRow]) -> bool: + return any(row.call_type == SEARCH_CALL_TYPE for row in rows) + + +class TestWebSearchInterceptionSession: + @pytest.mark.covers( + "quota_management.spend_tracking.websearch_interception.bills_under_request_session", + exercised_on=("messages",), + ) + def test_intercepted_search_is_billed_under_the_request_session( + self, proxy: ProxyClient, resources: ResourceManager + ) -> None: + """One /v1/messages turn that runs an intercepted web search must produce an + ``asearch`` spend row in the same session as its ``anthropic_messages`` row, + billed separately and with its own request id.""" + marker: Final = unique_marker() + model: Final = f"e2e-websearch-session-{marker}" + model_id: Final = proxy.create_model( + model, LiteLLMParamsBody(model=BEDROCK_INVOKE_BACKEND, aws_region_name="us-east-1") + ) + resources.defer(lambda: proxy.delete_model(model_id)) + key: Final = resources.key(models=[model]) + session_id: Final = f"e2e-websearch-session-{marker}" + + response: Final = unwrap( + proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=512, + tools=[AnthropicWebSearchTool(type="web_search_20250305", name="web_search", max_uses=1)], + messages=[ + ChatMessage( + role="user", + content=f"Use web search to find one recent news headline about Anthropic ({marker}).", + ) + ], + ), + session_id=session_id, + ) + ) + block_types: Final = tuple(block.type for block in response.content or ()) + assert "web_search_tool_result" in block_types, ( + f"precondition: the turn never ran an intercepted search, so there is no search row to attribute. " + f"blocks={block_types}" + ) + + rows: Final = proxy.poll_logs_for_session(session_id, min_rows=2, predicate=_has_search_row) + by_call_type: Final = {row.call_type or "" for row in rows} + assert SEARCH_CALL_TYPE in by_call_type, ( + f"session {session_id} has no {SEARCH_CALL_TYPE} row, so the intercepted search was billed under a " + f"different session and the session view misses its cost. call_types={sorted(by_call_type)} " + f"rows={[(row.call_type, row.request_id, row.spend) for row in rows]}" + ) + search_rows: Final = tuple(row for row in rows if row.call_type == SEARCH_CALL_TYPE) + turn_rows: Final = tuple(row for row in rows if row.call_type != SEARCH_CALL_TYPE) + assert turn_rows, f"session {session_id} carries only search rows: {rows!r}" + assert all(row.session_id == session_id for row in rows), ( + f"session view returned rows outside {session_id}: {[row.session_id for row in rows]}" + ) + assert all((row.spend or 0.0) > 0 for row in search_rows), ( + f"an intercepted search must stay a separately billed row: {[row.spend for row in search_rows]}" + ) + assert {row.request_id for row in search_rows}.isdisjoint({row.request_id for row in turn_rows}), ( + "a search row reused its parent turn's request_id instead of keeping its own: " + f"{[(row.call_type, row.request_id) for row in rows]}" + ) diff --git a/tests/e2e/router/reliability_support.py b/tests/e2e/router/reliability_support.py index 3d5b76f6408..4388e7f11bc 100644 --- a/tests/e2e/router/reliability_support.py +++ b/tests/e2e/router/reliability_support.py @@ -22,6 +22,7 @@ from pydantic import ValidationError from proxy_client import ProxyClient from e2e_config import CHEAP_OPENAI_MODEL, PROXY_BASE_URL, unique_marker from e2e_http import NetworkError, StreamHead, StreamingResponse +from transport import Transport from models import ( CacheControl, ChatMessage, @@ -274,9 +275,36 @@ def chat_turns_override( ) -> StreamingResponse: """POST /chat/completions with an optional per-request router_settings_override, returning the raw outcome so tests read status, body, and reliability headers.""" - return proxy.transport.send( + return chat_turns_override_via( + proxy.transport, key, model, turns, override=override, stream=stream, cache=cache, max_tokens=max_tokens + ) + + +def chat_override_via( + transport: Transport, + key: str, + model: str, + content: str, + override: RouterSettingsOverride | None = None, +) -> StreamingResponse: + """`chat_override` aimed at one replica's transport (from `proxy.replicas`) instead of + the client's default, for cells that must know which gateway took the call.""" + return chat_turns_override_via(transport, key, model, [ChatMessage(role="user", content=content)], override=override) + + +def chat_turns_override_via( + transport: Transport, + key: str, + model: str, + turns: Sequence[ChatMessage], + override: RouterSettingsOverride | None = None, + stream: bool = False, + cache: dict[str, bool] | None = {"no-cache": True}, + max_tokens: int = 512, +) -> StreamingResponse: + return transport.send( "/chat/completions", - headers=proxy.transport.bearer(key), + headers=transport.bearer(key), json=ReliabilityChatBody( model=model, messages=turns, diff --git a/tests/e2e/router/test_reliability_cooldowns_e2e.py b/tests/e2e/router/test_reliability_cooldowns_e2e.py index 769971e1533..ce3bce32880 100644 --- a/tests/e2e/router/test_reliability_cooldowns_e2e.py +++ b/tests/e2e/router/test_reliability_cooldowns_e2e.py @@ -6,21 +6,33 @@ way (a 500, a 429, a 401, or a timeout) holding all of the group's shuffle weigh with an `allowed_fails_policy` of zero for that error class and a short `cooldown_time`, plus a healthy backup at weight 0. The first call, retries off, surfaces the failure to the customer as-is and benches the deployment. The proxy -records the bench off the request path, and a sibling replica only sees it on -its next read of the cooldown keys from Redis, which the cooldown cache does at -most every 1s (DEFAULT_COOLDOWN_REDIS_READ_INTERVAL_SECONDS). So for +writes the bench to Redis before it answers that failure, and a sibling replica +only sees it on its next read of the cooldown keys from Redis, which the +cooldown cache does at most once per COOLDOWN_REDIS_READ_INTERVAL_SECONDS per +key (DEFAULT_COOLDOWN_REDIS_READ_INTERVAL_SECONDS, 1s). So for REPLICA_PROPAGATION_SECONDS after the trip, a window kept far wider than that -so this cell asserts the trip and the recovery rather than how fast siblings -catch up, every answer has to be either the deployment's own failure or a 200 -from the backup, which the proxy names in x-litellm-model-id, and at least one -replica has to have served from the backup by then. From then until shortly -before the cooldown can lapse, every call has to land on the backup whichever -replica takes it. Then the test polls until the weighted shuffle opens on the -failing deployment again and the same failure comes back (or, for the 429 pair, -its own 200 once the key's rpm window has reset): that is the recovery, since a -benched deployment is one the router will try again, not one it forgot. Its -deadline counts from the last failure a stale replica caused, because every -failure re-arms the cooldown. +so the trip-then-recover cells assert the trip and the recovery rather than how +fast siblings catch up, every answer has to be either the deployment's own +failure or a 200 from the backup, which the proxy names in x-litellm-model-id, +and at least one replica has to have served from the backup by then. From then +until shortly before the cooldown can lapse, every call has to land on the +backup whichever replica takes it. Then the test polls until the weighted +shuffle opens on the failing deployment again and the same failure comes back +(or, for the 429 pair, its own 200 once the key's rpm window has reset): that +is the recovery, since a benched deployment is one the router will try again, +not one it forgot. Its deadline counts from the last failure a stale replica +caused, because every failure re-arms the cooldown. + +The sibling cell is the one that asserts the speed. It addresses two gateways +from PROXY_REPLICA_URLS by name, warms the second with a healthy call so its +router has already read the failing deployment's cooldown key from Redis and +started the read interval on it, trips the deployment through the first, waits +the interval plus a margin, and then sends the second replica exactly one call, +which has to come back from the backup. One call, because a poll that reached +the failing deployment through the second replica would bench it there too and +hide whether the first replica's bench ever travelled. A stack addressed only +through its load balancer cannot pin which replica takes a call, so the cell is +skipped at collection unless LITELLM_PROXY_REPLICA_URLS names at least two. The failures are the same real ones the retry tests use: a 1ms deadline and a bogus key on the real backend, and this proxy standing in as the upstream for @@ -37,7 +49,7 @@ from dataclasses import dataclass import pytest from complexity_router_client import ComplexityRouterClient -from e2e_config import CHEAP_OPENAI_MODEL, unique_marker +from e2e_config import CHEAP_OPENAI_MODEL, PROXY_REPLICA_URLS, unique_marker from e2e_http import StreamingResponse from lifecycle import ResourceManager from models import KeyGenerateBody, RouterSettingsOverride @@ -45,6 +57,7 @@ from reliability_support import ( COOLDOWN_SECONDS, REPLICA_PROPAGATION_SECONDS, chat_override, + chat_override_via, create_always_5xx_deployment, create_always_rate_limited_deployment, create_always_timing_out_deployment, @@ -54,12 +67,15 @@ from reliability_support import ( model_id_of, spend_only_request_of, ) +from transport import Transport pytestmark = pytest.mark.e2e RECOVERY_GRACE_SECONDS = 10 PROPAGATION_POLL_SECONDS = 0.25 BENCH_MARGIN_SECONDS = 4.0 +COOLDOWN_REDIS_READ_INTERVAL_SECONDS = 1.0 +SIBLING_READ_MARGIN_SECONDS = 1.0 def _call_without_retries(client: ComplexityRouterClient, key: str, group: str) -> StreamingResponse: @@ -68,6 +84,31 @@ def _call_without_retries(client: ComplexityRouterClient, key: str, group: str) ) +def _call_replica_without_retries(transport: Transport, key: str, group: str) -> StreamingResponse: + return chat_override_via( + transport, key, group, f"say hi {unique_marker()}", override=RouterSettingsOverride(num_retries=0) + ) + + +@dataclass(frozen=True, slots=True) +class _Replica: + url: str + transport: Transport + + +def _two_replicas(client: ComplexityRouterClient) -> tuple[_Replica, _Replica]: + first, second, *_ = (_Replica(url, transport) for url, transport in client.proxy.replicas.items()) + return first, second + + +def _warm_cooldown_reads(replica: _Replica, key: str) -> None: + warmed = chat_override_via(replica.transport, key, CHEAP_OPENAI_MODEL, f"say hi {unique_marker()}") + assert warmed.status_code == 200, ( + f"{replica.url} should have answered a healthy {CHEAP_OPENAI_MODEL} call before the trip, got " + f"{warmed.status_code}: {warmed.body[:300]}" + ) + + def _assert_served_by_backup(resp: StreamingResponse, backup: str, when: str) -> None: assert resp.status_code == 200, ( f"{when} the group should have served from the backup, got {resp.status_code}: {resp.body[:300]}" @@ -176,6 +217,47 @@ class TestReliabilityCooldowns: _assert_trips_then_recovers(client, scoped_key, group, failing, backup, failure_status=500) + @pytest.mark.covers("reliability.cooldown.sibling_replica.serves_backup_within_read_interval") + @pytest.mark.skipif( + len(PROXY_REPLICA_URLS) < 2, + reason=( + "this cell trips a deployment through one gateway and reads the bench from another, so " + f"LITELLM_PROXY_REPLICA_URLS has to name at least two distinct gateways, got {PROXY_REPLICA_URLS}" + ), + ) + def test_sibling_replica_serves_backup_within_redis_read_interval( + self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str + ) -> None: + tripping, sibling = _two_replicas(client) + + upstream = f"reliability-cooldown-sibling-upstream-{unique_marker()}" + upstream_id = create_bad_base_deployment(client.proxy, upstream) + resources.defer(lambda: client.proxy.delete_model(upstream_id)) + + group = f"reliability-cooldown-sibling-{unique_marker()}" + failing = create_always_5xx_deployment( + client.proxy, group, upstream, scoped_key, cooldown_time=COOLDOWN_SECONDS + ) + resources.defer(lambda: client.proxy.delete_model(failing)) + backup = create_zero_weight_backup_deployment(client.proxy, group) + resources.defer(lambda: client.proxy.delete_model(backup)) + + _warm_cooldown_reads(sibling, scoped_key) + + tripped = _call_replica_without_retries(tripping.transport, scoped_key, group) + assert tripped.status_code == 500, ( + f"the first call through {tripping.url} should have surfaced the deployment's own 500, got " + f"{tripped.status_code}: {tripped.body[:300]}" + ) + tripped_at = time.monotonic() + + time.sleep(COOLDOWN_REDIS_READ_INTERVAL_SECONDS + SIBLING_READ_MARGIN_SECONDS) + _assert_served_by_backup( + _call_replica_without_retries(sibling.transport, scoped_key, group), + backup, + f"{time.monotonic() - tripped_at:.1f}s after {tripping.url} benched {failing}, on {sibling.url}", + ) + @pytest.mark.covers("reliability.cooldown.429.trips_then_recovers") def test_429_trips_cooldown_then_recovers( self, client: ComplexityRouterClient, resources: ResourceManager, scoped_key: str diff --git a/tests/e2e/test_proxy_client.py b/tests/e2e/test_proxy_client.py index 0c4aed5bd65..a8f07ed6dd7 100644 --- a/tests/e2e/test_proxy_client.py +++ b/tests/e2e/test_proxy_client.py @@ -338,6 +338,10 @@ class TestParseReplicaUrls: def test_falls_back_to_the_data_plane_address_when_unset(self) -> None: assert parse_replica_urls("", "http://lb") == ("http://lb",) + def test_collapses_repeated_gateway_addresses_to_one_replica(self) -> None: + raw: Final = "http://127.0.0.1:4010,http://127.0.0.1:4010/,http://127.0.0.1:4011,http://127.0.0.1:4010" + assert parse_replica_urls(raw, "http://lb") == ("http://127.0.0.1:4010", "http://127.0.0.1:4011") + def _answers(answers: Iterable[str]) -> ReplicaRead[str]: it: Final = iter(answers) diff --git a/tests/e2e/transport.py b/tests/e2e/transport.py index a3eec815441..87aad0d08de 100644 --- a/tests/e2e/transport.py +++ b/tests/e2e/transport.py @@ -312,7 +312,11 @@ CONTROL_PLANE_PREFIXES: tuple[str, ...] = ( "/global", "/config", "/guardrails", + "/credentials", "/router/settings", + "/audit", + "/public", + "/v2/login", "/openapi.json", ) diff --git a/tests/integration/_support/wire.py b/tests/integration/_support/wire.py index acc51dd4497..0c6acfde96c 100644 --- a/tests/integration/_support/wire.py +++ b/tests/integration/_support/wire.py @@ -1,7 +1,8 @@ from __future__ import annotations +import ssl import threading -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -38,7 +39,7 @@ class Wire: @contextmanager -def wire_server(respond: Callable[[Request], Reply]) -> Iterator[Wire]: +def wire_server(respond: Callable[[Request], Reply], tls: ssl.SSLContext | None = None) -> Generator[Wire, None, None]: """Owned TCP peer; requests traverse the real HTTP client and serialization.""" received: Final[SimpleQueue[Request]] = SimpleQueue() errors: Final[SimpleQueue[Exception]] = SimpleQueue() @@ -50,7 +51,8 @@ def wire_server(respond: Callable[[Request], Reply]) -> Iterator[Wire]: def respond(self) -> None: request: Final = Request( - self.command, self.path, + self.command, + self.path, {name.lower(): value for name, value in self.headers.items()}, self.rfile.read(int(self.headers.get("content-length", "0"))), ) @@ -99,11 +101,20 @@ def wire_server(respond: Callable[[Request], Reply]) -> Iterator[Wire]: class OwnedHTTPServer(ThreadingHTTPServer): daemon_threads = False + def server_bind(self) -> None: + super().server_bind() + if tls is not None: + self.socket = tls.wrap_socket(self.socket, server_side=True) + with OwnedHTTPServer(("127.0.0.1", 0), Handler) as server: thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}) thread.start() try: - yield Wire(f"http://127.0.0.1:{server.server_port}", received, disconnected) + yield Wire( + f"{'https' if tls is not None else 'http'}://127.0.0.1:{server.server_port}", + received, + disconnected, + ) finally: server.shutdown() thread.join(timeout=6) diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index e1b5940935f..d12e3ae4620 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -169,6 +169,12 @@ "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_failed_result_reports_failed_status_and_fal_error": [ "other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error" ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_auto_duration_omits_duration_and_queues": [ + "other.provider_wire.fal_ai.h3_auto_duration_omits_duration_and_queues" + ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_oversized_size_uses_top_resolution_tier_and_queues": [ + "other.provider_wire.fal_ai.h3_oversized_size_uses_top_resolution_tier" + ], "tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row": [ "other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing" ], @@ -184,6 +190,12 @@ "tests/integration/providers/test_fal_ai_passthrough_wire.py::test_fal_queue_submit_charges_and_polls_pass_through_free": [ "other.provider_wire.fal_ai.passthrough_queue_submit_charges_and_polls_do_not" ], + "tests/integration/providers/test_fal_ai_passthrough_wire.py::test_fal_queue_submit_prices_string_resolution_like_the_integer": [ + "other.provider_wire.fal_ai.passthrough_queue_submit_prices_string_resolution_like_integer" + ], + "tests/integration/providers/test_fal_ai_passthrough_wire.py::test_fal_queue_submit_to_catalog_key_the_pricer_cannot_price_is_rejected_not_forwarded": [ + "other.provider_wire.fal_ai.passthrough_queue_submit_rejects_unpriceable_catalog_key" + ], "tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_edit_inlines_upload_as_data_url_and_charges_keyed_row": [ "other.provider_wire.fal_ai.image_edit_json_data_urls_and_keyed_pricing" ], @@ -193,6 +205,12 @@ "tests/integration/providers/test_fal_ai_chat_wire.py::test_fal_moondream3_chat_sends_prompt_image_and_reasoning": [ "other.provider_wire.fal_ai.moondream3_chat_query_wire_and_token_pricing" ], + "tests/integration/providers/test_fal_ai_chat_wire.py::test_fal_moondream3_chat_rejects_non_string_reasoning_effort_before_the_wire": [ + "other.provider_wire.fal_ai.chat_non_string_reasoning_effort_rejected_before_wire" + ], + "tests/integration/providers/test_fal_ai_image_wire.py::test_fal_flux_dev_generation_without_deployment_api_base_uses_global_api_base": [ + "other.provider_wire.fal_ai.global_api_base_routes_image_generation" + ], "tests/integration/providers/test_xiaomi_mimo_wire.py::test_xiaomi_mimo_nonstream_surfaces_reasoning_and_charges_registry_price[mimo-v2.6-pro]": [ "other.provider_wire.xiaomi_mimo.reasoning_content_and_registry_pricing" ], @@ -208,6 +226,15 @@ "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_h3_video_create_uses_canonical_body_and_status_path": [ "other.provider_wire.fal_ai.video_queue_create_status_and_content_download" ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_result_probe_carries_the_deployment_extra_headers": [ + "other.provider_wire.fal_ai.video_result_probe_forwards_extra_headers" + ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_result_probe_reuses_the_ssl_verify_false_client": [ + "other.provider_wire.fal_ai.video_result_probe_honors_ssl_verify" + ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_provider_hanging_up_on_the_result_probe_keeps_the_completed_status": [ + "other.provider_wire.fal_ai.video_result_probe_hangup_stays_completed" + ], "tests/integration/mcp/test_mcp_lifecycle.py::test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit": [ "mcp.call_tool.saved_headers.reach_actual_transport" ], diff --git a/tests/integration/providers/test_fal_ai_chat_wire.py b/tests/integration/providers/test_fal_ai_chat_wire.py index 2bb1ac3f168..8cfa51f4389 100644 --- a/tests/integration/providers/test_fal_ai_chat_wire.py +++ b/tests/integration/providers/test_fal_ai_chat_wire.py @@ -2,7 +2,6 @@ import json from pathlib import Path from typing import Final -import httpx import pytest from integration._support.client import Gateway from integration._support.wire import Reply, Request, wire_server @@ -57,7 +56,6 @@ def test_fal_moondream3_chat_sends_prompt_image_and_reasoning(gateway: Gateway) ) with wire_server(respond) as wire, gateway.scenario() as scenario: - wire_url: Final = wire.url model: Final = scenario.model(model=f"fal_ai/{_MODEL}", api_base=wire.url, api_key="synthetic-fal-key") response: Final = gateway.request( "POST", @@ -97,3 +95,32 @@ def test_fal_moondream3_chat_sends_prompt_image_and_reasoning(gateway: Gateway) + 7 * _catalog_cost(f"fal_ai/{_MODEL}", "output_cost_per_token") ) assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_MODEL}")] + + +@pytest.mark.covers("other.provider_wire.fal_ai.chat_non_string_reasoning_effort_rejected_before_wire") +def test_fal_moondream3_chat_rejects_non_string_reasoning_effort_before_the_wire(gateway: Gateway) -> None: + def respond(request: Request) -> Reply: + raise AssertionError(f"provider must not be reached: {request.method} {request.target}") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"fal_ai/{_MODEL}", api_base=wire.url, api_key="synthetic-fal-key") + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": _PROMPT}, + {"type": "image_url", "image_url": {"url": "https://example.com/pic.png"}}, + ], + } + ], + "reasoning_effort": {"level": "low"}, + }, + ) + assert response.status_code == 400, response.text + assert "reasoning_effort" in response.text + assert wire.drain() == () diff --git a/tests/integration/providers/test_fal_ai_image_wire.py b/tests/integration/providers/test_fal_ai_image_wire.py index 02f24f9e369..c85bcdae8b8 100644 --- a/tests/integration/providers/test_fal_ai_image_wire.py +++ b/tests/integration/providers/test_fal_ai_image_wire.py @@ -4,12 +4,15 @@ from pathlib import Path from typing import Final import httpx -import litellm import pytest -from integration._support.client import Gateway +import yaml +from integration._support.client import Gateway, object_value +from integration._support.process import owned_proxy from integration._support.wire import Reply, Request, wire_server from pydantic import JsonValue, TypeAdapter +import litellm + _GPT_IMAGE_MODEL: Final = "openai/gpt-image-2.5/flare/text-to-image" _FLUX_MODEL: Final = "fal-ai/flux/dev" _EDIT_MODEL: Final = "openai/gpt-image-2.5/flare/edit" @@ -301,6 +304,44 @@ def test_fal_flux_lora_depth_edit_sends_single_image_url_and_charges_flat_row(ga ] cost: Final = _response_cost(response) assert cost == _approx(_catalog_cost("fal_ai/fal-ai/flux-lora-depth")) - assert [(request.method, request.target) for request in wire.drain()] == [ - ("POST", "/fal-ai/flux-lora-depth") - ] + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/fal-ai/flux-lora-depth")] + + +@pytest.mark.covers("other.provider_wire.fal_ai.global_api_base_routes_image_generation") +def test_fal_flux_dev_generation_without_deployment_api_base_uses_global_api_base( + gateway: Gateway, tmp_path: Path +) -> None: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.headers["authorization"] == "Key synthetic-fal-key" + assert request.target == "/fal-ai/flux/dev" + assert _JSON_OBJECT.validate_json(request.body) == {"prompt": _PROMPT, "num_images": 1} + return Reply(body=_image_response(((f"{wire_url}/files/global.png", 1024, 1024),), _PROMPT)) + + with wire_server(respond) as wire: + wire_url: Final = wire.url + configuration: Final = _JSON_OBJECT.validate_python( + yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + ) + configuration["litellm_settings"] = { + **object_value(configuration["litellm_settings"]), + "api_base": wire.url, + } + path: Final = tmp_path / "global-api-base.yaml" + path.write_text(yaml.safe_dump(configuration)) + with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model(model=f"fal_ai/{_FLUX_MODEL}", api_key="synthetic-fal-key", api_base=None) + response: Final = candidate.request( + "POST", "/v1/images/generations", {"model": model, "prompt": _PROMPT, "n": 1} + ) + assert response.status_code == 200, response.text + payload: Final = _JSON_OBJECT.validate_json(response.content) + assert payload["data"] == [ + { + "url": f"{wire.url}/files/global.png", + "b64_json": None, + "revised_prompt": None, + "provider_specific_fields": {"width": 1024, "height": 1024, "content_type": "image/png"}, + } + ] + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/fal-ai/flux/dev")] diff --git a/tests/integration/providers/test_fal_ai_passthrough_wire.py b/tests/integration/providers/test_fal_ai_passthrough_wire.py index f103135124e..b80cc601b3b 100644 --- a/tests/integration/providers/test_fal_ai_passthrough_wire.py +++ b/tests/integration/providers/test_fal_ai_passthrough_wire.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from typing import Final import pytest @@ -84,3 +85,107 @@ def test_fal_queue_submit_charges_and_polls_pass_through_free(gateway: Gateway, ("GET", f"/{_MODEL}/requests/req-1/status"), ("GET", f"/{_MODEL}/requests/req-1"), ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.passthrough_queue_submit_rejects_unpriceable_catalog_key") +def test_fal_queue_submit_to_catalog_key_the_pricer_cannot_price_is_rejected_not_forwarded( + gateway: Gateway, tmp_path: Path +) -> None: + def respond(request: Request) -> Reply: + return Reply(body=json.dumps({"request_id": "req-1", "status": "IN_QUEUE"}).encode()) + + config: Final = tmp_path / "proxy_config.yaml" + config.write_text( + "model_list: []\n" + "general_settings:\n" + " master_key: os.environ/LITELLM_MASTER_KEY\n" + " database_url: os.environ/DATABASE_URL\n" + " store_model_in_db: true\n" + " disable_spend_logs: false\n" + " proxy_batch_write_at: 1\n" + "router_settings:\n" + " disable_cooldowns: true\n" + ) + with wire_server(respond) as wire: + with owned_proxy( + gateway, + tmp_path, + {"FAL_AI_QUEUE_API_BASE": wire.url, "FAL_AI_API_KEY": "synthetic-fal-key"}, + config=config, + ) as candidate: + submit: Final = candidate.request( + "POST", + "/fal_ai/fal-ai/moondream3-preview/query", + {"image_url": "https://example.com/in.png", "prompt": "one word"}, + ) + assert submit.status_code == 400, submit.text + assert "pricing" in submit.text + assert wire.drain() == () + + +@pytest.mark.covers("other.provider_wire.fal_ai.passthrough_queue_submit_prices_string_resolution_like_integer") +def test_fal_queue_submit_prices_string_resolution_like_the_integer(gateway: Gateway, tmp_path: Path) -> None: + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + return Reply(body=json.dumps({"request_id": "req-1", "status": "IN_QUEUE"}).encode()) + + numeric_body: Final = {"image_url": "https://example.com/in.png", "resolution": 512} + string_body: Final = {"image_url": "https://example.com/in.png", "resolution": "512"} + config: Final = tmp_path / "proxy_config.yaml" + config.write_text( + "model_list: []\n" + "general_settings:\n" + " master_key: os.environ/LITELLM_MASTER_KEY\n" + " database_url: os.environ/DATABASE_URL\n" + " store_model_in_db: true\n" + " disable_spend_logs: false\n" + " proxy_batch_write_at: 1\n" + "router_settings:\n" + " disable_cooldowns: true\n" + ) + with wire_server(respond) as wire: + with owned_proxy( + gateway, + tmp_path, + {"FAL_AI_QUEUE_API_BASE": wire.url, "FAL_AI_API_KEY": "synthetic-fal-key"}, + config=config, + ) as candidate: + numeric: Final = candidate.request("POST", f"/fal_ai/{_MODEL}", numeric_body) + assert numeric.status_code == 200, numeric.text + assert json.loads(numeric.content) == {"request_id": "req-1", "status": "IN_QUEUE"} + string: Final = candidate.request("POST", f"/fal_ai/{_MODEL}", string_body) + assert string.status_code == 200, string.text + assert json.loads(string.content) == {"request_id": "req-1", "status": "IN_QUEUE"} + numeric_rows: Final = eventually( + lambda: read_rows( + 'SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id=%s', + (numeric.headers["x-litellm-call-id"],), + ), + lambda values: len(values) == 1, + seconds=70, + ) + string_rows: Final = eventually( + lambda: read_rows( + 'SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id=%s', + (string.headers["x-litellm-call-id"],), + ), + lambda values: len(values) == 1, + seconds=70, + ) + numeric_spend_value: Final = numeric_rows[0]["spend"] + string_spend_value: Final = string_rows[0]["spend"] + assert isinstance(numeric_spend_value, (int, float)) + assert isinstance(string_spend_value, (int, float)) + numeric_spend: Final = float(numeric_spend_value) + string_spend: Final = float(string_spend_value) + assert numeric_spend > 0, f"resolution 512 logged {numeric_spend} spend" + assert string_spend > 0, f'resolution "512" logged {string_spend} spend' + assert numeric_spend == string_spend, ( + f'resolution 512 was billed {numeric_spend} but resolution "512" was billed {string_spend}' + ) + forwarded: Final = wire.drain() + assert [(request.method, request.target) for request in forwarded] == [ + ("POST", f"/{_MODEL}"), + ("POST", f"/{_MODEL}"), + ] + assert [json.loads(request.body) for request in forwarded] == [numeric_body, string_body] diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py index 276ea2868a7..90df4520b98 100644 --- a/tests/integration/providers/test_fal_ai_video_wire.py +++ b/tests/integration/providers/test_fal_ai_video_wire.py @@ -1,14 +1,58 @@ +import datetime +import ipaddress import json +import ssl import uuid +from pathlib import Path from typing import Final import pytest -from integration._support.client import Gateway +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from integration._support.client import JSON_OBJECT, Gateway, object_value, string_value from integration._support.wire import Reply, Request, wire_server _MODEL: Final = "bytedance/seedance-2.5/text-to-video" _H3_MODEL: Final = "minimax/h3/text-to-video" _MP4: Final = b"\x00\x00\x00\x18ftypmp42" + uuid.uuid4().bytes * 4 +_OVERSIZED_SIDE: Final = "9" * 30 + + +def _h3_queue_reply(request_id: str) -> Reply: + return Reply(body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode()) + + +def _write_self_signed_cert(cert_dir: Path) -> tuple[Path, Path]: + key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now: Final = datetime.datetime.now(datetime.timezone.utc) + name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + cert: Final = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=7)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + cert_file: Final = cert_dir / "cert.pem" + key_file: Final = cert_dir / "key.pem" + cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_file.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + return cert_file, key_file @pytest.mark.covers("other.provider_wire.fal_ai.video_queue_create_status_and_content_download") @@ -173,7 +217,217 @@ def test_fal_video_failed_result_reports_failed_status_and_fal_error(gateway: Ga video_id: Final = created["id"] status: Final = gateway.get(f"/v1/videos/{video_id}") assert status["status"] == "failed" - assert "input.reference_image_urls: Failed to download the file" in status["error"]["message"] + assert "input.reference_image_urls: Failed to download the file" in string_value( + object_value(status["error"])["message"] + ) content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content") assert content.status_code == 422, content.text assert "Failed to download the file" in content.text + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_result_probe_forwards_extra_headers") +def test_fal_result_probe_carries_the_deployment_extra_headers(gateway: Gateway) -> None: + request_id: Final = "fal-probe-req-" + uuid.uuid4().hex + marker: Final = uuid.uuid4().hex + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_H3_MODEL}" + return Reply( + body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode() + ) + assert request.method == "GET" + if request.target == f"/minimax/h3/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/minimax/h3/requests/{request_id}" + if request.headers.get("x-integration-routing") != marker: + return Reply(status=403, body=json.dumps({"detail": "routing header missing"}).encode()) + return Reply(body=json.dumps({"video": {"url": f"{wire_url}/files/{request_id}.mp4"}}).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + wire_url: Final = wire.url + model: Final = scenario.model( + model=f"fal_ai/{_H3_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + extra_headers={"x-integration-routing": marker}, + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a paper boat drifting across a puddle after rain", + "seconds": 6, + "size": "2k", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + response: Final = gateway.request("GET", f"/v1/videos/{video_id}") + assert response.status_code == 200, response.text + status: Final = JSON_OBJECT.validate_json(response.content) + assert status["status"] == "completed", status + assert status["error"] is None, status + assert [ + (request.method, request.target, request.headers.get("x-integration-routing")) for request in wire.drain() + ] == [ + ("POST", f"/{_H3_MODEL}", marker), + ("GET", f"/minimax/h3/requests/{request_id}/status", marker), + ("GET", f"/minimax/h3/requests/{request_id}", marker), + ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_result_probe_honors_ssl_verify") +def test_fal_result_probe_reuses_the_ssl_verify_false_client(gateway: Gateway, tmp_path: Path) -> None: + request_id: Final = "fal-tls-req-" + uuid.uuid4().hex + cert_file, key_file = _write_self_signed_cert(tmp_path) + context: Final = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certfile=cert_file, keyfile=key_file) + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_H3_MODEL}" + return Reply( + body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode() + ) + assert request.method == "GET" + if request.target == f"/minimax/h3/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/minimax/h3/requests/{request_id}" + return Reply(body=json.dumps({"video": {"url": f"{wire_url}/files/{request_id}.mp4"}}).encode()) + + with wire_server(respond, tls=context) as wire, gateway.scenario() as scenario: + wire_url: Final = wire.url + model: Final = scenario.model( + model=f"fal_ai/{_H3_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + ssl_verify=False, + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a paper boat drifting across a puddle after rain", + "seconds": 6, + "size": "2k", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + response: Final = gateway.request("GET", f"/v1/videos/{video_id}") + assert response.status_code == 200, response.text + status: Final = JSON_OBJECT.validate_json(response.content) + assert status["status"] == "completed", status + assert status["error"] is None, status + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", f"/{_H3_MODEL}"), + ("GET", f"/minimax/h3/requests/{request_id}/status"), + ("GET", f"/minimax/h3/requests/{request_id}"), + ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_result_probe_hangup_stays_completed") +def test_fal_provider_hanging_up_on_the_result_probe_keeps_the_completed_status(gateway: Gateway) -> None: + request_id: Final = "fal-hangup-req-" + uuid.uuid4().hex + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_H3_MODEL}" + return Reply( + body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode() + ) + assert request.method == "GET" + if request.target == f"/minimax/h3/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/minimax/h3/requests/{request_id}" + return Reply(chunks=(b"{",), abort_after=0) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"fal_ai/{_H3_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a paper boat drifting across a puddle after rain", + "seconds": 6, + "size": "2k", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + response: Final = gateway.request("GET", f"/v1/videos/{video_id}") + assert response.status_code == 200, response.text + status: Final = JSON_OBJECT.validate_json(response.content) + assert status["status"] == "completed", status + assert status["error"] is None, status + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", f"/{_H3_MODEL}"), + ("GET", f"/minimax/h3/requests/{request_id}/status"), + ("GET", f"/minimax/h3/requests/{request_id}"), + ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.h3_auto_duration_omits_duration_and_queues") +def test_fal_h3_auto_duration_omits_duration_and_queues(gateway: Gateway) -> None: + request_id: Final = "fal-h3-auto-" + uuid.uuid4().hex + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == f"/{_H3_MODEL}" + assert json.loads(request.body) == { + "prompt": "a cat playing volleyball on a beach", + "resolution": "768P", + "aspect_ratio": "16:9", + } + return _h3_queue_reply(request_id) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"fal_ai/{_H3_MODEL}", api_base=wire.url, api_key="synthetic-fal-key") + response: Final = gateway.request( + "POST", + "/v1/videos", + {"model": model, "prompt": "a cat playing volleyball on a beach", "seconds": "auto", "size": "1280x720"}, + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "queued" + assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_H3_MODEL}")] + + +@pytest.mark.covers("other.provider_wire.fal_ai.h3_oversized_size_uses_top_resolution_tier") +def test_fal_h3_oversized_size_uses_top_resolution_tier_and_queues(gateway: Gateway) -> None: + request_id: Final = "fal-h3-oversized-" + uuid.uuid4().hex + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == f"/{_H3_MODEL}" + assert json.loads(request.body) == { + "prompt": "a cat playing volleyball on a beach", + "duration": 5, + "resolution": "4K", + "aspect_ratio": "1:1", + } + return _h3_queue_reply(request_id) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=f"fal_ai/{_H3_MODEL}", api_base=wire.url, api_key="synthetic-fal-key") + response: Final = gateway.request( + "POST", + "/v1/videos", + { + "model": model, + "prompt": "a cat playing volleyball on a beach", + "seconds": "5", + "size": f"{_OVERSIZED_SIDE}x{_OVERSIZED_SIDE}", + }, + ) + assert response.status_code == 200, response.text + assert response.json()["status"] == "queued" + assert [(request.method, request.target) for request in wire.drain()] == [("POST", f"/{_H3_MODEL}")] diff --git a/tests/llm_translation/conftest.py b/tests/llm_translation/conftest.py index 567040c1d19..a88dcf4ae3e 100644 --- a/tests/llm_translation/conftest.py +++ b/tests/llm_translation/conftest.py @@ -7,6 +7,8 @@ import asyncio import importlib +from collections.abc import Generator +from typing import Final import pytest @@ -20,6 +22,7 @@ from tests._vcr_conftest_common import ( # noqa: E402,F401 emit_cassette_cache_session_banner, emit_vcr_classification_summary, emit_vcr_diagnostic_log, + guard_vcr_patch_points, install_live_call_probe, record_vcr_outcome, register_persister_if_enabled, @@ -37,17 +40,12 @@ def fake_openai_endpoint(): # Per-item respx detection (``apply_vcr_auto_marker_to_items``) handles # the vast majority of respx-vs-vcrpy conflicts automatically. The entries -# below are the persister's and the WebSocket VCR's own unit-test files, which -# exercise ``save_cassette`` / ``load_cassette`` against fakeredis and must not -# themselves run under a live cassette context. +# below are the persister's, the WebSocket VCR's, and the cassette patch-leak +# guard's own unit-test files, which exercise ``save_cassette`` / +# ``load_cassette`` against fakeredis or enter cassettes themselves and must +# not run under a live cassette context. _VCR_AUTO_MARKER_SKIP_FILES = frozenset( - {"test_vcr_redis_persister.py", "test_ws_vcr.py"} -) - -_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ( - "test_nvidia_nim.py::test_embedding_nvidia_nim", - "test_litellm_proxy_provider.py::test_litellm_gateway_from_sdk_embedding[False]", - "test_litellm_proxy_provider.py::test_litellm_gateway_from_sdk_embedding[True]", + {"test_vcr_redis_persister.py", "test_ws_vcr.py", "test_vcr_leak_guard.py"} ) @@ -77,6 +75,17 @@ def _vcr_outcome_gate(request, vcr): record_vcr_outcome(request, vcr) +@pytest.hookimpl(wrapper=True, trylast=True) +def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, object, object]: + try: + result: Final = yield + except BaseException: + guard_vcr_patch_points(item, teardown_failed=True) + raise + guard_vcr_patch_points(item, teardown_failed=False) + return result + + def pytest_configure(config): _verbose_state.remember_pluginmanager(config) reset_vcr_diag_dir() @@ -172,7 +181,6 @@ def pytest_collection_modifyitems(config, items): apply_vcr_auto_marker_to_items( items, skip_files=_VCR_AUTO_MARKER_SKIP_FILES, - skip_nodeid_suffixes=_VCR_INCOMPATIBLE_NODEID_SUFFIXES, ) custom_logger_tests = [ diff --git a/tests/llm_translation/test_litellm_proxy_provider.py b/tests/llm_translation/test_litellm_proxy_provider.py index 8630259877d..a10fc55ecc5 100644 --- a/tests/llm_translation/test_litellm_proxy_provider.py +++ b/tests/llm_translation/test_litellm_proxy_provider.py @@ -2,6 +2,8 @@ import json import re from datetime import datetime from io import BytesIO +from pathlib import Path +from typing import Final from unittest.mock import AsyncMock @@ -12,7 +14,12 @@ import pytest from unittest.mock import MagicMock, patch from litellm.llms.custom_httpx.http_handler import HTTPHandler, AsyncHTTPHandler import pytest_asyncio -from openai import AsyncOpenAI +from openai import AsyncOpenAI, OpenAI +from openai.types import CreateEmbeddingResponse, Embedding +from openai.types.create_embedding_response import Usage + +from tests.capturing_transport import CapturingTransport +from tests._vcr_conftest_common import rewound_new_episodes_cassette @pytest.mark.asyncio @@ -87,62 +94,61 @@ async def test_litellm_gateway_from_sdk_structured_output(): assert "json_schema" in json_schema -@pytest.mark.parametrize("is_async", [False, True]) +_GATEWAY_EMBEDDING_RESPONSE: Final = CreateEmbeddingResponse( + object="list", + data=(Embedding(object="embedding", index=0, embedding=(0.1, 0.2, 0.3)),), + model="my-vllm-model", + usage=Usage(prompt_tokens=2, total_tokens=2), +) + + +async def _gateway_embedding_via_injected_client( + is_async: bool, +) -> tuple[CapturingTransport, litellm.EmbeddingResponse]: + transport: Final = CapturingTransport(_GATEWAY_EMBEDDING_RESPONSE) + response: Final = ( + await litellm.aembedding( + model="litellm_proxy/my-vllm-model", + input="Hello world", + client=AsyncOpenAI(api_key="fake-key", http_client=httpx.AsyncClient(transport=transport)), + api_base="my-custom-api-base", + ) + if is_async + else litellm.embedding( + model="litellm_proxy/my-vllm-model", + input="Hello world", + client=OpenAI(api_key="fake-key", http_client=httpx.Client(transport=transport)), + api_base="my-custom-api-base", + ) + ) + return transport, response + + +@pytest.mark.parametrize("is_async", (False, True)) @pytest.mark.asyncio -async def test_litellm_gateway_from_sdk_embedding(is_async): +async def test_litellm_gateway_from_sdk_embedding(is_async: bool): litellm.set_verbose = True litellm._turn_on_debug() - captured_bodies = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured_bodies.append(json.loads(request.content)) - return httpx.Response( - 200, - json={ - "object": "list", - "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}], - "model": "my-vllm-model", - "usage": {"prompt_tokens": 2, "total_tokens": 2}, - }, - ) - - if is_async: - from openai import AsyncOpenAI - - openai_client = AsyncOpenAI( - api_key="fake-key", - http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), - ) - response = await litellm.aembedding( - model="litellm_proxy/my-vllm-model", - input="Hello world", - client=openai_client, - api_base="my-custom-api-base", - ) - else: - from openai import OpenAI - - openai_client = OpenAI( - api_key="fake-key", - http_client=httpx.Client(transport=httpx.MockTransport(handler)), - ) - response = litellm.embedding( - model="litellm_proxy/my-vllm-model", - input="Hello world", - client=openai_client, - api_base="my-custom-api-base", - ) - - request_body = captured_bodies[0] - print("Request body - {}".format(request_body)) + transport, response = await _gateway_embedding_via_injected_client(is_async) + request_body: Final = transport.request_bodies[0] assert "Hello world" == request_body["input"] assert "my-vllm-model" == request_body["model"] assert "encoding_format" not in request_body assert response.data[0]["embedding"] == [0.1, 0.2, 0.3] +@pytest.mark.asyncio +async def test_litellm_gateway_from_sdk_embedding_under_foreign_cassette(tmp_path: Path): + with rewound_new_episodes_cassette(tmp_path): + sync_transport, _ = await _gateway_embedding_via_injected_client(is_async=False) + async_transport, _ = await _gateway_embedding_via_injected_client(is_async=True) + + assert tuple(body["input"] for body in sync_transport.request_bodies) == ("Hello world",) + assert tuple(body["input"] for body in async_transport.request_bodies) == ("Hello world",) + + @pytest.mark.parametrize("is_async", [False, True]) @pytest.mark.asyncio async def test_litellm_gateway_from_sdk_image_generation(is_async): diff --git a/tests/llm_translation/test_nvidia_nim.py b/tests/llm_translation/test_nvidia_nim.py index d5942e674d0..0f16c01fd2f 100644 --- a/tests/llm_translation/test_nvidia_nim.py +++ b/tests/llm_translation/test_nvidia_nim.py @@ -1,17 +1,21 @@ import json from datetime import datetime +from typing import Final from unittest.mock import AsyncMock import httpx import pytest +from openai.types import CreateEmbeddingResponse, Embedding +from openai.types.create_embedding_response import Usage as EmbeddingUsage from unittest.mock import patch, MagicMock import litellm from litellm import Choices, Message, ModelResponse, EmbeddingResponse, Usage from litellm import completion from base_rerank_unit_tests import BaseLLMRerankTest +from tests.capturing_transport import CapturingTransport def test_completion_nvidia_nim(): @@ -63,33 +67,23 @@ def test_embedding_nvidia_nim(): litellm.set_verbose = True from openai import OpenAI - captured_bodies = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured_bodies.append(json.loads(request.content)) - return httpx.Response( - 200, - json={ - "object": "list", - "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}], - "model": "nvidia/nv-embedqa-e5-v5", - "usage": {"prompt_tokens": 6, "total_tokens": 6}, - }, + transport: Final = CapturingTransport( + CreateEmbeddingResponse( + object="list", + data=(Embedding(object="embedding", index=0, embedding=(0.1, 0.2, 0.3)),), + model="nvidia/nv-embedqa-e5-v5", + usage=EmbeddingUsage(prompt_tokens=6, total_tokens=6), ) - - client = OpenAI( - api_key="fake-api-key", - http_client=httpx.Client(transport=httpx.MockTransport(handler)), ) - response = litellm.embedding( + client: Final = OpenAI(api_key="fake-api-key", http_client=httpx.Client(transport=transport)) + response: Final = litellm.embedding( model="nvidia_nim/nvidia/nv-embedqa-e5-v5", input="What is the meaning of life?", input_type="passage", dimensions=1024, client=client, ) - request_body = captured_bodies[0] - print("request_body: ", request_body) + request_body: Final = transport.request_bodies[0] assert request_body["input"] == "What is the meaning of life?" assert request_body["model"] == "nvidia/nv-embedqa-e5-v5" assert request_body["input_type"] == "passage" diff --git a/tests/llm_translation/test_vcr_leak_guard.py b/tests/llm_translation/test_vcr_leak_guard.py new file mode 100644 index 00000000000..5372342790b --- /dev/null +++ b/tests/llm_translation/test_vcr_leak_guard.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Final + +import httpx +import httpx2 +import pytest + +from tests._vcr_conftest_common import ( + detect_vcr_patch_leak, + guard_vcr_patch_points, + restore_vcr_patch_points, + rewound_new_episodes_cassette, +) + +_ORIGINAL_MOCK_HANDLE_ASYNC_REQUEST: Final = httpx.MockTransport.handle_async_request +_ORIGINAL_HTTPX2_MOCK_HANDLE_ASYNC_REQUEST: Final = httpx2.MockTransport.handle_async_request + + +@pytest.fixture +def leaked_cassette_dir(tmp_path: Path): + context: Final = rewound_new_episodes_cassette(tmp_path) + context.__enter__() + yield tmp_path + context.__exit__(None, None, None) + + +def test_no_leak_when_no_cassette_is_active(): + assert detect_vcr_patch_leak() is None + + +def test_leaked_cassette_is_detected_named_and_restorable(leaked_cassette_dir: Path): + leak: Final = detect_vcr_patch_leak() + + assert leak is not None + assert {"httpx.MockTransport.handle_async_request", "aiohttp.client.ClientSession._request"} <= set( + leak.patch_points + ) + assert leak.cassette_paths == (str(leaked_cassette_dir / "rewound_owner.yaml"),) + + restore_vcr_patch_points() + + assert detect_vcr_patch_leak() is None + assert httpx.MockTransport.handle_async_request is _ORIGINAL_MOCK_HANDLE_ASYNC_REQUEST + + +def test_leak_is_detected_on_every_transport_family_vcrpy_patches(leaked_cassette_dir: Path): + leak: Final = detect_vcr_patch_leak() + + assert leak is not None + assert "httpx2.MockTransport.handle_async_request" in leak.patch_points + assert httpx2.MockTransport.handle_async_request is not _ORIGINAL_HTTPX2_MOCK_HANDLE_ASYNC_REQUEST + + restore_vcr_patch_points() + + assert httpx2.MockTransport.handle_async_request is _ORIGINAL_HTTPX2_MOCK_HANDLE_ASYNC_REQUEST + + +def test_guard_fails_the_leaking_test_and_restores_the_originals(request, leaked_cassette_dir: Path): + with pytest.raises(pytest.fail.Exception, match=re.escape(request.node.nodeid)) as failure: + guard_vcr_patch_points(request.node, teardown_failed=False) + + assert str(leaked_cassette_dir / "rewound_owner.yaml") in str(failure.value) + assert detect_vcr_patch_leak() is None + + +def test_guard_restores_silently_when_the_teardown_already_failed(request, leaked_cassette_dir: Path): + guard_vcr_patch_points(request.node, teardown_failed=True) + + assert detect_vcr_patch_leak() is None diff --git a/tests/local_testing/conftest.py b/tests/local_testing/conftest.py index 228457f4d55..d03f074f557 100644 --- a/tests/local_testing/conftest.py +++ b/tests/local_testing/conftest.py @@ -93,7 +93,6 @@ _VCR_INCOMPATIBLE_FILES = frozenset( # carry no real provider cost. _VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ( "test_router.py::test_router_text_completion_client", - "test_embedding.py::test_encoding_format_omitted_by_default_for_openai_sdk", ) diff --git a/tests/local_testing/test_embedding.py b/tests/local_testing/test_embedding.py index c119334da6f..acbc4f20405 100644 --- a/tests/local_testing/test_embedding.py +++ b/tests/local_testing/test_embedding.py @@ -15,6 +15,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import litellm from litellm import completion, completion_cost, embedding +from openai.types import CreateEmbeddingResponse +from openai.types.create_embedding_response import Usage as EmbeddingUsage +from tests.capturing_transport import CapturingTransport from tests.fake_openai_endpoint import FAKE_OPENAI_API_BASE litellm.set_verbose = False @@ -1268,23 +1271,15 @@ def test_encoding_format_omitted_by_default_for_openai_sdk(monkeypatch): Optional global override: `LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT`. """ monkeypatch.delenv("LITELLM_DEFAULT_EMBEDDING_ENCODING_FORMAT", raising=False) - captured_bodies = [] - - def handler(request: httpx.Request) -> httpx.Response: - captured_bodies.append(json.loads(request.content)) - return httpx.Response( - 200, - json={ - "object": "list", - "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}], - "model": "text-embedding-ada-002", - "usage": {"prompt_tokens": 1, "total_tokens": 1}, - }, + transport = CapturingTransport( + CreateEmbeddingResponse( + object="list", + data=(Embedding(object="embedding", index=0, embedding=(0.1, 0.2, 0.3)),), + model="text-embedding-ada-002", + usage=EmbeddingUsage(prompt_tokens=1, total_tokens=1), ) - - client = openai.OpenAI( - api_key="sk-test", http_client=httpx.Client(transport=httpx.MockTransport(handler)) ) + client = openai.OpenAI(api_key="sk-test", http_client=httpx.Client(transport=transport)) response = embedding( model="text-embedding-ada-002", @@ -1294,7 +1289,7 @@ def test_encoding_format_omitted_by_default_for_openai_sdk(monkeypatch): ) assert response.data[0]["embedding"] == [0.1, 0.2, 0.3] - assert "encoding_format" not in captured_bodies[0], ( + assert "encoding_format" not in transport.request_bodies[0], ( "encoding_format should be omitted from the upstream request when not provided by user" ) diff --git a/tests/logging_callback_tests/test_langsmith_unit_test.py b/tests/logging_callback_tests/test_langsmith_unit_test.py index 17cd63d8974..341f71f3b4a 100644 --- a/tests/logging_callback_tests/test_langsmith_unit_test.py +++ b/tests/logging_callback_tests/test_langsmith_unit_test.py @@ -356,10 +356,12 @@ async def test_langsmith_key_based_logging(): # tenant_id should not be in headers if not provided assert "x-tenant-id" not in call_args[1]["headers"] + assert call_args[1]["headers"]["Content-Type"] == "application/json" + # Verify the request body contains the expected data - request_body = call_args[1]["json"] + request_body = json.loads(call_args[1]["content"]) assert "post" in request_body - assert len(request_body["post"]) == 1 # Should contain one run + assert len(request_body["post"]) == 1 # EXPECTED BODY expected_body = { @@ -404,7 +406,7 @@ async def test_langsmith_key_based_logging(): } # Print both bodies for debugging - actual_body = call_args[1]["json"] + actual_body = json.loads(call_args[1]["content"]) print("\nExpected body:") print(json.dumps(expected_body, indent=2)) print("\nActual body:") diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index cff3d5f78c6..1ad11287e4c 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -456,9 +456,15 @@ async def test_sse_mcp_handler_mock(): """Test the SSE MCP handler functionality""" from litellm.proxy._types import UserAPIKeyAuth - # Mock the SSE session manager and its methods - mock_sse_session_manager = AsyncMock() - mock_sse_session_manager.handle_request = AsyncMock() + read_stream, write_stream = MagicMock(), MagicMock() + + @asynccontextmanager + async def connect_sse(scope, receive, send): + yield read_stream, write_stream + + mock_sse = MagicMock() + mock_sse.connect_sse.side_effect = connect_sse + run = AsyncMock() # Mock scope, receive, send with proper ASGI scope format mock_scope = { @@ -483,13 +489,14 @@ async def test_sse_mcp_handler_mock(): ) with ( + patch("litellm.proxy._experimental.mcp_server.server.server.run", run), patch( "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), patch( - "litellm.proxy._experimental.mcp_server.server.sse_session_manager", - mock_sse_session_manager, + "litellm.proxy._experimental.mcp_server.server.sse", + mock_sse, ), patch( "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", @@ -504,10 +511,8 @@ async def test_sse_mcp_handler_mock(): # Call the handler await handle_sse_mcp(mock_scope, mock_receive, mock_send) - # Verify SSE session manager handle_request was called - mock_sse_session_manager.handle_request.assert_called_once_with( - mock_scope, mock_receive, mock_send - ) + assert run.await_args.args[:2] == (read_stream, write_stream) + assert mock_sse.connect_sse.call_args.args[0]["path"] == "/mcp/sse" @pytest.mark.asyncio @@ -545,7 +550,10 @@ async def test_sse_mcp_handler_propagates_passthrough_401(): True, ), patch( - "litellm.proxy._experimental.mcp_server.server.sse_session_manager", + "litellm.proxy._experimental.mcp_server.server.sse", + ) as transport, + patch( + "litellm.proxy._experimental.mcp_server.server.server.run", AsyncMock(), ), patch( @@ -569,6 +577,7 @@ async def test_sse_mcp_handler_propagates_passthrough_401(): with pytest.raises(HTTPException) as excinfo: await handle_sse_mcp(mock_scope, mock_receive, mock_send) + transport.connect_sse.assert_not_called() assert excinfo.value.status_code == 401 assert excinfo.value.headers and "WWW-Authenticate" in excinfo.value.headers @@ -2956,7 +2965,7 @@ async def test_call_mcp_tool_uses_manager_permission_lookup(): mcp_info={"server_name": "test_server"}, ) - expected_response = [TextContent(type="text", text="ok")] + expected_response = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) with ( patch.object( @@ -3032,7 +3041,7 @@ async def test_call_mcp_tool_resolves_unprefixed_tool_name_and_checks_permission mcp_info={"server_name": "test_server"}, ) - expected_response = [TextContent(type="text", text="ok")] + expected_response = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) with ( patch.object( diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index 6c20ef135ba..b260d240f29 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -552,6 +552,50 @@ class TestExecuteSessionOperationSurfacesTransportError: with pytest.raises(asyncio.CancelledError): await client._execute_session_operation(transport_ctx, _op) + @pytest.mark.asyncio + @pytest.mark.parametrize("failure_phase", ("early", "late", "mixed")) + @patch("litellm.experimental_mcp_client.client.ClientSession") + async def test_response_close_preserves_cancellation_and_original_errors(self, session_class, failure_phase): + closed: Final = asyncio.Event() + close_error: Final = httpx2.ReadError("response close failed") + connect_error: Final = httpx2.ConnectError("another request failed before cancellation") + cancelled: Final = asyncio.CancelledError("caller cancelled") + + class FailingCloseStream(httpx2.AsyncByteStream): + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b"pending" + + async def aclose(self) -> None: + closed.set() + raise close_error + + client: Final = MCPClient(server_url="https://example.com/mcp") + async with client._create_httpx_client_factory( + transport=httpx2.MockTransport(lambda _: httpx2.Response(200, stream=FailingCloseStream())) + )() as http_client: + response: Final = await http_client.send(http_client.build_request("POST", client.server_url), stream=True) + + async def initialize(): + if failure_phase == "early": + await response.aclose() + raise cancelled + + async def close_transport(*args): + if failure_phase == "early": + return + try: + await response.aclose() + except httpx2.ReadError as error: + failures: Final = [error, connect_error] if failure_phase == "mixed" else [error] + raise _FakeExceptionGroup("transport", [_FakeExceptionGroup("reader", failures)]) + + self._make_session(session_class, initialize) + expected: Final = close_error if failure_phase == "early" else connect_error if failure_phase == "mixed" else cancelled + with pytest.raises(type(expected)) as caught: + await client._execute_session_operation(self._make_transport(close_transport), AsyncMock(), http_client) + assert caught.value is expected + assert closed.is_set() + @pytest.mark.asyncio @patch("litellm.experimental_mcp_client.client.ClientSession") async def test_cleanup_error_after_success_is_swallowed(self, mock_session_cls): @@ -568,6 +612,94 @@ class TestExecuteSessionOperationSurfacesTransportError: assert result == "done" + @pytest.mark.asyncio + @patch("litellm.experimental_mcp_client.client.ClientSession") + async def test_session_entry_failure_still_closes_transport(self, session_class): + failure: Final = RuntimeError("session dispatcher did not start") + session_class.return_value.__aenter__ = AsyncMock(side_effect=failure) + closed: Final = asyncio.Event() + + async def close_transport(*args): + await anyio.lowlevel.checkpoint() + closed.set() + + transport: Final = self._make_transport(close_transport) + client: Final = MCPClient(server_url="https://example.com/mcp") + with pytest.raises(RuntimeError) as caught: + await client._execute_session_operation(transport, AsyncMock()) + assert caught.value is failure + assert closed.is_set() + + @pytest.mark.asyncio + @pytest.mark.parametrize("original_error", (False, True)) + @patch("litellm.experimental_mcp_client.client.ClientSession") + async def test_session_exit_cancellation_preserves_original_failure(self, session_class, original_error): + self._make_session(session_class, AsyncMock(return_value=None)) + cancelled: Final = asyncio.CancelledError("cancelled while closing session") + session_class.return_value.__aexit__ = AsyncMock(side_effect=cancelled) + original: Final = RuntimeError("operation failed") + transport: Final = self._make_transport(None) + client: Final = MCPClient(server_url="https://example.com/mcp") + + async def operation(session): + if original_error: + raise original + return "done" + + with pytest.raises(RuntimeError if original_error else asyncio.CancelledError) as caught: + await client._execute_session_operation(transport, operation) + assert caught.value is (original if original_error else cancelled) + transport.__aexit__.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize("signal_type", (SystemExit, KeyboardInterrupt)) + @pytest.mark.parametrize("phase", ("session", "transport")) + @patch("litellm.experimental_mcp_client.client.ClientSession") + async def test_cleanup_preserves_process_exit(self, session_class, phase, signal_type): + self._make_session(session_class, AsyncMock(return_value=None)) + signal: Final = signal_type("process stopping") + if phase == "session": + session_class.return_value.__aexit__ = AsyncMock(side_effect=signal) + transport: Final = self._make_transport(signal if phase == "transport" else None) + client: Final = MCPClient(server_url="https://example.com/mcp") + with pytest.raises(signal_type) as caught: + await client._execute_session_operation(transport, AsyncMock(return_value="done")) + assert caught.value is signal + transport.__aexit__.assert_awaited_once() + + @pytest.mark.asyncio + @patch("litellm.experimental_mcp_client.client.ClientSession") + async def test_session_and_termination_share_one_cleanup_deadline(self, session_class): + self._make_session(session_class, AsyncMock(return_value=None)) + deleting: Final = asyncio.Event() + + async def close_session(*args): + await anyio.sleep(1) + + async def respond(request: httpx2.Request) -> httpx2.Response: + deleting.set() + await anyio.sleep_forever() + raise AssertionError("termination unexpectedly resumed") + + client: Final = MCPClient(server_url="https://example.com/mcp") + http_client: Final = client._create_httpx_client_factory(transport=httpx2.MockTransport(respond))() + session_class.return_value.__aexit__ = AsyncMock(side_effect=close_session) + + async def close_transport(*args): + await http_client.delete(client.server_url) + + before: Final = anyio.current_time() + try: + with pytest.raises(asyncio.CancelledError): + await client._execute_session_operation( + self._make_transport(close_transport), AsyncMock(return_value="completed"), http_client=http_client + ) + assert deleting.is_set() + assert 4.8 <= anyio.current_time() - before < 5.8 + finally: + await http_client.aclose() + + class TestMCPClientResolvedAuth: """A pre-resolved httpx2.Auth is attached to the upstream client's auth= slot.""" @@ -736,7 +868,7 @@ async def test_run_with_session_quiet_on_error_demotes_warning_to_debug(): async def _op(_session): raise boom - async def _fake_exec(_transport_ctx, _operation): + async def _fake_exec(_transport_ctx, _operation, http_client=None): raise boom with patch.object(client, "_create_transport_context", return_value=(object(), None)): @@ -1486,7 +1618,9 @@ async def test_http_response_handler_preserves_success_and_http_errors(status_co client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) async with client._create_httpx_client_factory(transport=httpx2.MockTransport(respond))() as http_client: operation: Final = client._execute_session_operation( - streamable_http_client(client.server_url, http_client=http_client), lambda session: session.list_tools() + streamable_http_client(client.server_url, http_client=http_client), + lambda session: session.list_tools(), + http_client=http_client, ) if status_code == 200: result: Final = await asyncio.wait_for(operation, timeout=3) @@ -1816,13 +1950,14 @@ async def test_interrupted_http_response_preserves_the_transport_failure() -> No def respond(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, headers={"Content-Type": "application/json"}, stream=_InterruptedHTTPBody()) - async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: - client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) + client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) + async with client._create_httpx_client_factory(transport=httpx2.MockTransport(respond))() as http_client: with pytest.raises(httpx2.RemoteProtocolError, match="secret-incomplete-response"): await asyncio.wait_for( client._execute_session_operation( streamable_http_client(client.server_url, http_client=http_client), lambda session: session.list_tools(), + http_client=http_client, ), timeout=3, ) @@ -1906,7 +2041,7 @@ async def test_optional_discovery_capabilities_and_errors( "jsonrpc": "2.0", "id": payload.id, "result": { - "protocolVersion": payload.params["protocolVersion"], + "protocolVersion": (payload.params or {})["protocolVersion"], "capabilities": {} if outcome == "absent" else {advertised if outcome == "other_capability" else capability: {}}, @@ -1985,7 +2120,7 @@ async def test_optional_discovery_uses_each_sessions_capabilities(supports_first return httpx2.Response(202) result: Final = ( { - "protocolVersion": payload.params["protocolVersion"], + "protocolVersion": (payload.params or {})["protocolVersion"], "capabilities": next(capabilities), "serverInfo": {"name": "changing", "version": "1"}, } @@ -2030,7 +2165,7 @@ async def test_optional_discovery_preserves_cancellation(method: str) -> None: "jsonrpc": "2.0", "id": payload.id, "result": { - "protocolVersion": payload.params["protocolVersion"], + "protocolVersion": (payload.params or {})["protocolVersion"], "capabilities": {"resources": {}, "prompts": {}}, "serverInfo": {"name": "pending", "version": "1"}, }, @@ -2112,7 +2247,7 @@ async def test_optional_discovery_collects_all_pages(method: str, session_id: st "jsonrpc": "2.0", "id": payload.id, "result": { - "protocolVersion": payload.params["protocolVersion"], + "protocolVersion": (payload.params or {})["protocolVersion"], "capabilities": {"prompts": {}, "resources": {}}, "serverInfo": {"name": "paged", "version": "1"}, }, @@ -2200,7 +2335,7 @@ async def test_optional_discovery_rejects_incomplete_walks( "jsonrpc": "2.0", "id": payload.id, "result": { - "protocolVersion": payload.params["protocolVersion"], + "protocolVersion": (payload.params or {})["protocolVersion"], "capabilities": {"prompts": {}, "resources": {}}, "serverInfo": {"name": "interrupted", "version": "1"}, }, @@ -2281,7 +2416,7 @@ async def test_optional_discovery_allows_exhaustion_at_page_cap(method: str, mon return httpx2.Response(202) if payload.method == "initialize": result: Final = { - "protocolVersion": payload.params["protocolVersion"], + "protocolVersion": (payload.params or {})["protocolVersion"], "capabilities": {"prompts": {}, "resources": {}}, "serverInfo": {"name": "empty-pages", "version": "1"}, } @@ -2455,3 +2590,309 @@ def test_public_mcp_import_preserves_incompatible_sdk_error() -> None: assert not isinstance(caught.value, ModuleNotFoundError) assert caught.value.__cause__ is None assert "litellm[mcp]" not in str(caught.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("grouped", (False, True)) +@pytest.mark.parametrize("raise_on_error", (False, True)) +@pytest.mark.parametrize("termination", ("ok", "failure", "hang")) +async def test_outer_deadline_delivers_session_termination(termination: str, grouped: bool, raise_on_error: bool) -> None: + deleted: Final = asyncio.Event() + started: Final = asyncio.Event() + + async def respond(request: httpx2.Request) -> httpx2.Response: + await anyio.lowlevel.checkpoint() + if request.method == "DELETE": + first_termination: Final = not deleted.is_set() + deleted.set() + if termination == "hang" and first_termination: + await anyio.sleep_forever() + return httpx2.Response(500 if termination == "failure" else 200) + if request.method == "GET": + return httpx2.Response(405) + payload: Final = _JSONRPC_MESSAGE_ADAPTER.validate_json(request.content) + if not isinstance(payload, JSONRPCRequest): + return httpx2.Response(202) + if payload.method == "initialize": + return httpx2.Response( + 200, + headers={"mcp-session-id": "cancel-owned-session"}, + json={ + "jsonrpc": "2.0", + "id": payload.id, + "result": { + "protocolVersion": "2025-11-25", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "cancellation-peer", "version": "1"}, + }, + }, + ) + if payload.method == "tools/list": + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": {"tools": []}}) + started.set() + await anyio.sleep_forever() + raise AssertionError("cancelled request resumed") + + client: Final = _MockTransportClient(respond, server_url="https://example.com/mcp", timeout=30) + + async def invoke(): + with anyio.fail_after(0.2): + pending: Final = client.call_tool(CallToolRequestParams(name="slow", arguments={}), raise_on_error=raise_on_error) + if grouped: + await asyncio.gather(pending) + else: + await pending + + before: Final = anyio.current_time() + with pytest.raises(TimeoutError): + await invoke() + assert started.is_set() + assert deleted.is_set(), "Cancellation must deliver DELETE before returning to the caller" + + assert anyio.current_time() - before < 6.5 + assert await client.list_tools(raise_on_error=True) == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("original_error", (False, True)) +async def test_task_cancellation_during_cleanup_preserves_failure(original_error: bool) -> None: + deleting: Final = asyncio.Event() + drained: Final = asyncio.Event() + original: Final = RuntimeError("operation failed before teardown") + + async def respond(request: httpx2.Request) -> httpx2.Response: + if request.method == "DELETE": + deleting.set() + try: + await anyio.sleep_forever() + finally: + drained.set() + if request.method == "GET": + return httpx2.Response(405) + payload: Final = _JSONRPC_MESSAGE_ADAPTER.validate_json(request.content) + if not isinstance(payload, JSONRPCRequest): + return httpx2.Response(202) + return httpx2.Response( + 200, + headers={"mcp-session-id": "cleanup-session"}, + json={ + "jsonrpc": "2.0", + "id": payload.id, + "result": { + "protocolVersion": (payload.params or {})["protocolVersion"], + "capabilities": {}, + "serverInfo": {"name": "cleanup-peer", "version": "1"}, + }, + }, + ) + + async def operation(session: mcp_client_module.ClientSession) -> str: + if original_error: + raise original + return "completed" + + client: Final = _MockTransportClient(respond, server_url="https://example.com/mcp", timeout=30) + task: Final = asyncio.create_task(client.run_with_session(operation)) + await asyncio.wait_for(deleting.wait(), 2) + for _ in range(3): + task.cancel() + await asyncio.sleep(0) + with pytest.raises(RuntimeError if original_error else asyncio.CancelledError) as caught: + await task + assert drained.is_set(), "Caller must wait for termination cleanup to finish" + if original_error: + assert caught.value is original + else: + assert task.cancelled() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("original_error", (False, True)) +@pytest.mark.parametrize("cancel_mode", ("task", "scope")) +async def test_http_close_cancellation_cannot_turn_into_success(original_error: bool, cancel_mode: str) -> None: + closing: Final = asyncio.Event() + drained: Final = asyncio.Event() + original: Final = RuntimeError("failed before HTTP close") + + class ClosingHTTPClient(httpx2.AsyncClient): + async def aclose(self) -> None: + closing.set() + try: + await anyio.sleep_forever() + finally: + drained.set() + + class ClosingMCPClient(MCPClient): + def _create_transport_context(self): + http_client: Final = ClosingHTTPClient(transport=httpx2.MockTransport(lambda _: httpx2.Response(200))) + return streamable_http_client(self.server_url, http_client=http_client), http_client + + async def _execute_session_operation(self, transport_ctx, operation, http_client=None): + if original_error: + raise original + return "completed" + + client: Final = ClosingMCPClient(server_url="https://example.com/mcp") + + async def invoke() -> str: + with anyio.fail_after(0.05 if cancel_mode == "scope" else None): + return await client.run_with_session(AsyncMock()) + + task: Final = asyncio.create_task(invoke()) + await asyncio.wait_for(closing.wait(), 2) + if cancel_mode == "task": + task.cancel() + cancellation_type: Final = asyncio.CancelledError if cancel_mode == "task" else TimeoutError + with pytest.raises(RuntimeError if original_error else cancellation_type) as caught: + await task + assert drained.is_set(), "Caller must wait for HTTP closure to finish" + if original_error: + assert caught.value is original + elif cancel_mode == "task": + assert task.cancelled() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cancel_mode", ("scope", "task", "wait_for", "read_timeout")) +@pytest.mark.parametrize("concurrency", (1, 5)) +@pytest.mark.parametrize("termination", ("ok", "hang", "hang_body")) +@pytest.mark.parametrize("raise_on_error", (False, True)) +async def test_cancellation_delivers_termination_over_tcp( + cancel_mode: str, concurrency: int, termination: str, raise_on_error: bool +) -> None: + started: Final = asyncio.Event() + terminations: Final[list[bytes]] = [] + starts: Final[list[bytes]] = [] + stop: Final = asyncio.Event() + connections: Final[list[asyncio.Task[None]]] = [] + + async def handle_connection(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + connection: Final = asyncio.current_task() + assert connection is not None + connections.append(connection) + try: + request_line: Final = await reader.readline() + if not request_line: + return + method: Final = request_line.split()[0] + headers: Final = await reader.readuntil(b"\r\n\r\n") + length: Final = next( + ( + int(line.split(b":", 1)[1]) + for line in headers.splitlines() + if line.lower().startswith(b"content-length:") + ), + 0, + ) + try: + body: Final = await reader.readexactly(length) + except asyncio.IncompleteReadError: + return + if method == b"DELETE": + terminations.append(body) + if termination != "ok": + await stop.wait() + return + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + elif method == b"GET": + writer.write(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + else: + payload: Final = json.loads(body) + if payload["method"] == "tools/call": + if termination == "hang_body": + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + b"Content-Length: 200\r\nConnection: close\r\n\r\n" + ) + await writer.drain() + starts.append(body) + if len(starts) == concurrency: + started.set() + await stop.wait() + return + if payload["method"] == "initialize": + response: Final = json.dumps( + { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "tcp-peer", "version": "1"}, + }, + } + ).encode() + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nMcp-Session-Id: tcp-session\r\n" + + f"Content-Length: {len(response)}\r\nConnection: close\r\n\r\n".encode() + + response + ) + else: + writer.write(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + listener: Final = await asyncio.start_server(handle_connection, "127.0.0.1", 0) + port: Final = listener.sockets[0].getsockname()[1] + client: Final = MCPClient( + server_url=f"http://127.0.0.1:{port}/mcp", timeout=2 if cancel_mode == "read_timeout" else 0.5 if termination != "ok" else 30 + ) + + async def calls(): + results: Final = await asyncio.gather( + *( + client.call_tool(CallToolRequestParams(name="slow", arguments={}), raise_on_error=raise_on_error) + for _ in range(concurrency) + ), + return_exceptions=cancel_mode == "read_timeout", + ) + if cancel_mode == "read_timeout": + if raise_on_error: + assert all(isinstance(result, TimeoutError) for result in results) + else: + assert all(isinstance(result, CallToolResult) and result.is_error for result in results) + return results + + async def invoke(): + if cancel_mode == "scope": + with anyio.fail_after(0.2): + return await calls() + return await calls() + + try: + task: Final = asyncio.create_task(invoke()) + await asyncio.wait_for(started.wait(), 3) + if cancel_mode == "task": + task.cancel() + expected_error: Final = ( + TimeoutError + if cancel_mode == "read_timeout" + else asyncio.CancelledError + if cancel_mode == "task" + else TimeoutError + ) + if cancel_mode == "read_timeout": + done, _ = await asyncio.wait((task,), timeout=8) + assert task in done, "Read timeout and bounded cleanup must complete without external cancellation" + await task + elif cancel_mode == "wait_for": + with pytest.raises(expected_error): + await asyncio.wait_for(task, 0.2) + else: + with pytest.raises(expected_error): + await task + assert len(starts) == concurrency + assert len(terminations) == concurrency, "Each cancelled call must send DELETE over a fresh TCP connection" + finally: + stop.set() + if not task.done(): + task.cancel() + await asyncio.wait((task,), timeout=8) + listener.close() + for connection in connections: + connection.cancel() + closed: Final = await asyncio.wait_for(asyncio.gather(*connections, return_exceptions=True), 2) + assert all(result is None or isinstance(result, asyncio.CancelledError) for result in closed), closed + await asyncio.wait_for(listener.wait_closed(), 2) diff --git a/tests/test_litellm/integrations/conftest.py b/tests/test_litellm/integrations/conftest.py new file mode 100644 index 00000000000..adc8e36e0af --- /dev/null +++ b/tests/test_litellm/integrations/conftest.py @@ -0,0 +1,96 @@ +import functools +import http.server +import ipaddress +import queue +import ssl +import threading +from collections.abc import Iterator +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Final + +import pytest + + +@dataclass(frozen=True, slots=True) +class TlsSink: + url: str + certificate_path: str + received: "queue.Queue[str]" + + +class _RecordingOtelHandler(http.server.BaseHTTPRequestHandler): + def __init__(self, *args: object, received: "queue.Queue[str]", **kwargs: object) -> None: + self._received: Final = received + super().__init__(*args, **kwargs) + + def do_POST(self) -> None: + length: Final = int(self.headers.get("Content-Length") or 0) + if length: + self.rfile.read(length) + self._received.put(self.path) + self.send_response(200) + self.send_header("Content-Type", "application/x-protobuf") + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: + pass + + +def write_self_signed_cert(directory: Path, stem: str) -> tuple[Path, Path]: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name: Final = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + certificate: Final = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1)) + .not_valid_after(datetime.now(timezone.utc) + timedelta(hours=1)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + certificate_path: Final = directory / f"{stem}.crt" + certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path: Final = directory / f"{stem}.key" + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + return certificate_path, key_path + + +@pytest.fixture +def tls_sink(tmp_path: Path) -> Iterator[TlsSink]: + certificate_path, key_path = write_self_signed_cert(tmp_path, "sink") + received: queue.Queue[str] = queue.Queue() + context: Final = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(str(certificate_path), str(key_path)) + server: Final = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), functools.partial(_RecordingOtelHandler, received=received) + ) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread: Final = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield TlsSink( + url=f"https://127.0.0.1:{server.server_port}", + certificate_path=str(certificate_path), + received=received, + ) + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_components.py b/tests/test_litellm/integrations/otel/test_otel_v2_components.py index 0c95049ce05..79747ac9956 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -2,14 +2,17 @@ baggage helpers, metrics, the typed coercion helpers, mapper branches, span-name builders, and the registry validator's failure paths. Needs the OTel SDK.""" +import contextlib import json import threading +import time from collections.abc import Iterator from contextvars import Context as ContextVarContext from dataclasses import replace from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer import pytest +import requests pytest.importorskip("opentelemetry") @@ -18,6 +21,9 @@ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ( # noqa: ) from opentelemetry import baggage # noqa: E402 from opentelemetry.context import attach, detach # noqa: E402 +from opentelemetry._logs.severity import SeverityNumber # noqa: E402 +from opentelemetry.sdk._logs import LogData, LogRecord # noqa: E402 +from opentelemetry.sdk._logs.export import LogExportResult # noqa: E402 from opentelemetry.sdk.metrics import MeterProvider # noqa: E402 from opentelemetry.sdk.metrics.export import InMemoryMetricReader # noqa: E402 from opentelemetry.sdk.trace import TracerProvider # noqa: E402 @@ -29,11 +35,14 @@ from opentelemetry.sdk.trace.export import ( # noqa: E402 from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402 InMemorySpanExporter, ) -from opentelemetry.trace import SpanKind, get_current_span # noqa: E402 +from opentelemetry.sdk.util.instrumentation import InstrumentationScope # noqa: E402 +from opentelemetry.trace import SpanKind, TraceFlags, get_current_span # noqa: E402 from opentelemetry.trace.propagation.tracecontext import ( # noqa: E402 TraceContextTextMapPropagator, ) +import litellm # noqa: E402 +from conftest import TlsSink # noqa: E402 from litellm.integrations.otel.plumbing import context as ctx_mod # noqa: E402 from litellm.integrations.otel.plumbing import providers # noqa: E402 from litellm.integrations.otel.model.config import OpenTelemetryV2Config # noqa: E402 @@ -1414,3 +1423,97 @@ def test_genai_mapper_guardrail_cost_in_spend_attr(): billed = dict(entry) del billed["guardrail_cost_in_spend"] assert LiteLLM.GUARDRAIL_COST_IN_SPEND not in GenAIMapper().map(GuardrailSpanData.from_logging_entry(billed)) + + +def _isolate_v2_otlp_tls_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "SSL_VERIFY", + "SSL_CERT_FILE", + "OTEL_EXPORTER_OTLP_CERTIFICATE", + "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", + "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE", + "OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TIMEOUT", "2") + monkeypatch.setattr(litellm, "ssl_verify", True) + + +def test_v2_otlp_http_span_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_v2_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + cfg = OpenTelemetryV2Config(exporter="otlp_http", endpoint=tls_sink.url) + _export_one_span(cfg) + assert tls_sink.received.get(timeout=5) == "/v1/traces" + + +def test_v2_http_json_span_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_v2_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + cfg = OpenTelemetryV2Config(exporter="http/json", endpoint=tls_sink.url) + _export_one_span(cfg) + assert tls_sink.received.get(timeout=5) == "/v1/traces" + + +def test_v2_otlp_http_metric_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_v2_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + cfg = OpenTelemetryV2Config(exporter="otlp_http", endpoint=tls_sink.url) + reader = providers.build_metric_reader(cfg) + provider = MeterProvider(metric_readers=[reader]) + try: + provider.get_meter("v2-tls-test").create_counter("tls_export_test").add(1) + assert provider.force_flush(), "metric flush failed" + assert tls_sink.received.get(timeout=5) == "/v1/metrics" + finally: + provider.shutdown() + + +def test_v2_otlp_http_log_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_v2_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + cfg = OpenTelemetryV2Config(exporter="otlp_http", endpoint=tls_sink.url) + exporter = providers.build_log_exporter(cfg) + try: + record = LogRecord( + timestamp=int(time.time() * 1e9), + observed_timestamp=int(time.time() * 1e9), + trace_id=0, + span_id=0, + trace_flags=TraceFlags(0), + severity_number=SeverityNumber.INFO, + body="v2-tls-test", + ) + log_data = LogData(log_record=record, instrumentation_scope=InstrumentationScope("v2-tls-test")) + result = exporter.export([log_data]) + assert result is LogExportResult.SUCCESS, f"log export failed: {result}" + assert tls_sink.received.get(timeout=5) == "/v1/logs" + finally: + exporter.shutdown() + + +def test_v2_otlp_http_export_skips_verification_when_ssl_verify_false( + monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink +) -> None: + _isolate_v2_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_VERIFY", "false") + cfg = OpenTelemetryV2Config(exporter="otlp_http", endpoint=tls_sink.url) + _export_one_span(cfg) + assert tls_sink.received.get(timeout=5) == "/v1/traces" + + +def test_v2_otlp_http_export_rejects_untrusted_collector_by_default( + monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink +) -> None: + + + _isolate_v2_otlp_tls_env(monkeypatch) + cfg = OpenTelemetryV2Config(exporter="otlp_http", endpoint=tls_sink.url) + provider = providers.build_tracer_provider(cfg) + provider.get_tracer("probe").start_span("probe").end() + try: + with contextlib.suppress(requests.exceptions.SSLError): + provider.force_flush() + assert tls_sink.received.empty(), "sink received a request it should never have trusted" + finally: + provider.shutdown() diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 7bf4533979a..f787d370f04 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -1664,7 +1664,7 @@ class TestEnableAnthropicPromptCaching: points = self._points(model="us.anthropic.claude-sonnet-4-5-20250929-v1:0", provider="bedrock") assert [p["index"] for p in points] == [None, -1] - @pytest.mark.parametrize("model, provider", [("gpt-4o", "openai"), ("gemini-2.0-flash", "gemini")]) + @pytest.mark.parametrize("model, provider", [("gpt-4o", "openai")]) def test_non_anthropic_providers_never_injected(self, monkeypatch, model, provider): """These report supports_prompt_caching=True but never consume cache_control markers.""" from litellm.utils import supports_prompt_caching diff --git a/tests/test_litellm/integrations/test_langsmith_init.py b/tests/test_litellm/integrations/test_langsmith_init.py index f56d2310e73..9c0650baee6 100644 --- a/tests/test_litellm/integrations/test_langsmith_init.py +++ b/tests/test_litellm/integrations/test_langsmith_init.py @@ -1,13 +1,17 @@ import asyncio +import json import os +from datetime import datetime, timezone +from decimal import Decimal from typing import Final from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest - import litellm from litellm.integrations.langsmith import LangsmithLogger +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.types.integrations.langsmith import LangsmithQueueObject @@ -219,6 +223,121 @@ class TestLangsmithLoggerInit: assert len(logger.log_queue) == 1 +class TestLangsmithBatchSerialization: + async def _logger(self, transport_handler, tenant_id=None): + logger = LangsmithLogger( + langsmith_api_key="test-key", + langsmith_project="test-project", + langsmith_base_url="https://api.smith.langchain.com", + langsmith_tenant_id=tenant_id, + ) + if logger._flush_task is not None: + logger._flush_task.cancel() + handler = AsyncHTTPHandler() + await handler.client.aclose() + handler.client = httpx.AsyncClient( + transport=httpx.MockTransport(transport_handler) + ) + logger.async_httpx_client = handler + return logger + + @staticmethod + def _capturing_transport(captured): + async def handle(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, request=request, json={"ok": True}) + + return handle + + def _queue(self, logger, extra): + return [ + LangsmithQueueObject( + data={"id": "run-1", "name": "LLMRun", "extra": extra}, + credentials=logger.default_credentials, + ) + ] + + @pytest.mark.asyncio + async def test_datetime_and_decimal_metadata_reach_langsmith_as_strings(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger(self._capturing_transport(captured)) + logger.log_queue = self._queue( + logger, + { + "created_at": datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + "spend": Decimal("0.0042"), + }, + ) + + await logger.async_send_batch() + + assert len(captured) == 1, "batch was dropped instead of being sent" + body = json.loads(captured[0].content) + assert body["post"][0]["extra"] == { + "created_at": "2026-01-02 03:04:05+00:00", + "spend": "0.0042", + } + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_nan_metadata_is_dropped_instead_of_shipping_invalid_json(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger(self._capturing_transport(captured)) + logger.log_queue = self._queue(logger, {"score": float("nan")}) + + await logger.async_send_batch() + + assert captured == [], ( + "nan metadata must abort the batch: a bare NaN token is invalid JSON and LangSmith rejects it" + ) + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_batch_declares_json_content_type(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger(self._capturing_transport(captured)) + logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"}) + + await logger.async_send_batch() + + assert captured[0].headers["content-type"] == "application/json", ( + "a content= body carries no implicit content type; LangSmith refuses it without this header" + ) + assert captured[0].url.path.endswith("/api/v1/runs/batch") + assert captured[0].headers["x-api-key"] == "test-key" + assert "x-tenant-id" not in captured[0].headers + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_tenant_id_is_forwarded_on_the_batch_request(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + logger = await self._logger( + self._capturing_transport(captured), tenant_id="tenant-1" + ) + logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"}) + + await logger.async_send_batch() + + assert captured[0].headers["x-tenant-id"] == "tenant-1" + await logger.async_httpx_client.client.aclose() + + @pytest.mark.asyncio + async def test_langsmith_error_response_does_not_propagate(self): + captured: list[httpx.Request] = [] # mutable-ok: transport capture buffer + + async def reject(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(422, request=request, text="bad run") + + logger = await self._logger(reject) + logger.log_queue = self._queue(logger, {"model": "gpt-4.1-mini"}) + + await logger.async_send_batch() + + assert len(captured) == 1, "the batch never left the process" + await logger.async_httpx_client.client.aclose() + + class TestLangsmithPrepareLogData: """Regression test for #24001: _prepare_log_data must inject usage_metadata into outputs so LangSmith's Cost column is populated.""" @@ -544,12 +663,10 @@ async def test_events_appended_during_flush_are_not_dropped(): credentials=logger.default_credentials, data={"id": "late"} ) - async def fake_post( - url: str, json: dict[str, list[dict[str, str]]], headers: dict[str, str] - ) -> MagicMock: + async def fake_post(url: str, content: str, headers: dict[str, str]) -> MagicMock: if not sent_batches: logger.log_queue.append(late_event) - sent_batches.append(json["post"]) + sent_batches.append(json.loads(content)["post"]) response = MagicMock() response.status_code = 200 response.raise_for_status = MagicMock() diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index bea9a38e9dd..974961f2eb5 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -1,5 +1,6 @@ import asyncio import concurrent.futures +import contextlib import gc import json import os @@ -9,21 +10,30 @@ import time import unittest import weakref from datetime import datetime, timedelta, timezone +from pathlib import Path from types import MappingProxyType -from parameterized import parameterized +from typing import Final from unittest.mock import MagicMock, patch +import pytest + # Adds the grandparent directory to sys.path to allow importing project modules from opentelemetry import trace -from opentelemetry.sdk._logs import LogData +from opentelemetry._logs.severity import SeverityNumber +from opentelemetry.sdk._logs import LogData, LogRecord from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider -from opentelemetry.sdk._logs.export import InMemoryLogExporter, SimpleLogRecordProcessor +from opentelemetry.sdk._logs.export import InMemoryLogExporter, LogExportResult, SimpleLogRecordProcessor from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import InMemoryMetricReader, MetricsData -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult +from opentelemetry.sdk.util.instrumentation import InstrumentationScope from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from parameterized import parameterized +import requests + +from conftest import TlsSink, write_self_signed_cert import litellm from litellm.integrations import opentelemetry as otel_module from litellm.integrations.opentelemetry import ( @@ -2081,6 +2091,138 @@ class TestOpenTelemetryEndpointNormalization(unittest.TestCase): self.assertEqual(traces, "http://collector:4318/v1/traces") +def _isolate_otlp_tls_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "SSL_VERIFY", + "SSL_CERT_FILE", + "OTEL_EXPORTER_OTLP_CERTIFICATE", + "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", + "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE", + "OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TIMEOUT", "2") + monkeypatch.setattr(litellm, "ssl_verify", True) + + +def _ended_span() -> tuple[TracerProvider, ReadableSpan]: + provider: Final = TracerProvider() + span = provider.get_tracer(__name__).start_span("tls-export-test") + span.end() + return provider, span + + +def _assert_export_rejected(processor, span: ReadableSpan, sink: TlsSink) -> None: + with contextlib.suppress(requests.exceptions.SSLError): + result: Final = processor.span_exporter.export([span]) + assert result is SpanExportResult.FAILURE, f"rejected export must report failure, got {result}" + assert sink.received.empty(), "sink received a request it should never have trusted" + + +def _otlp_http_otel(endpoint: str) -> OpenTelemetry: + return OpenTelemetry(config=OpenTelemetryConfig(exporter="otlp_http", endpoint=endpoint)) + + +def test_otlp_http_span_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + otel: Final = _otlp_http_otel(tls_sink.url) + processor: Final = otel._get_span_processor() + provider, span = _ended_span() + try: + result: Final = processor.span_exporter.export([span]) + assert result is SpanExportResult.SUCCESS, f"span export failed: {result}" + assert tls_sink.received.get(timeout=5) == "/v1/traces" + finally: + processor.shutdown() + provider.shutdown() + + +def test_otlp_http_metric_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + otel: Final = _otlp_http_otel(tls_sink.url) + reader: Final = otel._get_metric_reader() + provider: Final = MeterProvider(metric_readers=[reader]) + try: + provider.get_meter(__name__).create_counter("tls_export_test").add(1) + assert provider.force_flush(), "metric flush failed" + assert tls_sink.received.get(timeout=5) == "/v1/metrics" + finally: + provider.shutdown() + + +def test_otlp_http_log_export_trusts_ssl_cert_file(monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink) -> None: + _isolate_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + otel: Final = _otlp_http_otel(tls_sink.url) + exporter: Final = otel._get_log_exporter() + try: + record: Final = LogRecord( + timestamp=int(time.time() * 1e9), + observed_timestamp=int(time.time() * 1e9), + trace_id=0, + span_id=0, + trace_flags=trace.TraceFlags(0), + severity_number=SeverityNumber.INFO, + body="tls-export-test", + ) + log_data: Final = LogData(log_record=record, instrumentation_scope=InstrumentationScope("tls-export-test")) + result: Final = exporter.export([log_data]) + assert result is LogExportResult.SUCCESS, f"log export failed: {result}" + assert tls_sink.received.get(timeout=5) == "/v1/logs" + finally: + exporter.shutdown() + + +def test_otlp_http_export_skips_verification_when_ssl_verify_false( + monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink +) -> None: + _isolate_otlp_tls_env(monkeypatch) + monkeypatch.setenv("SSL_VERIFY", "false") + otel: Final = _otlp_http_otel(tls_sink.url) + processor: Final = otel._get_span_processor() + provider, span = _ended_span() + try: + result: Final = processor.span_exporter.export([span]) + assert result is SpanExportResult.SUCCESS, f"span export failed: {result}" + assert tls_sink.received.get(timeout=5) == "/v1/traces" + finally: + processor.shutdown() + provider.shutdown() + + +def test_otlp_http_export_rejects_untrusted_collector_by_default( + monkeypatch: pytest.MonkeyPatch, tls_sink: TlsSink +) -> None: + _isolate_otlp_tls_env(monkeypatch) + otel: Final = _otlp_http_otel(tls_sink.url) + processor: Final = otel._get_span_processor() + provider, span = _ended_span() + try: + _assert_export_rejected(processor, span, tls_sink) + finally: + processor.shutdown() + provider.shutdown() + + +def test_otel_certificate_env_takes_precedence_over_ssl_cert_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, tls_sink: TlsSink +) -> None: + _isolate_otlp_tls_env(monkeypatch) + unrelated_certificate, _ = write_self_signed_cert(tmp_path, "unrelated") + monkeypatch.setenv("SSL_CERT_FILE", tls_sink.certificate_path) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_CERTIFICATE", str(unrelated_certificate)) + otel: Final = _otlp_http_otel(tls_sink.url) + processor: Final = otel._get_span_processor() + provider, span = _ended_span() + try: + _assert_export_rejected(processor, span, tls_sink) + finally: + processor.shutdown() + provider.shutdown() + + class TestOpenTelemetryProtocolSelection(unittest.TestCase): """Test suite for verifying correct exporter selection based on protocol""" diff --git a/tests/test_litellm/integrations/test_prometheus_deployment_state_proxy_rejects.py b/tests/test_litellm/integrations/test_prometheus_deployment_state_proxy_rejects.py new file mode 100644 index 00000000000..fedfc2b0848 --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_deployment_state_proxy_rejects.py @@ -0,0 +1,101 @@ +""" +LIT-7701 attributes a pre_call_hook rejection's failure log to the model +group's single deployment (``model_id`` and ``custom_llm_provider``) and flags +it with ``PROXY_REJECTED_BEFORE_ROUTING_KEY``. The deployment health metrics +must keep treating such rejects as "no deployment picked": a key rate limit or +guardrail block never reached the deployment, so it must not flip +``litellm_deployment_state`` to partial outage or count as a deployment failure +response. A failure raised after the router picked a deployment (a post-call +guardrail block, a provider error) carries no flag and keeps its deployment labels. +""" + +import pytest +from fastapi import HTTPException +from prometheus_client import REGISTRY + +from litellm.constants import PROXY_REJECTED_BEFORE_ROUTING_KEY +from litellm.exceptions import GuardrailRaisedException +from litellm.integrations.prometheus import PrometheusLogger +from litellm.proxy._types import ProxyException +from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError + + +@pytest.fixture(autouse=True) +def cleanup_prometheus_registry(): + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + + yield + + for collector in list(REGISTRY._collector_to_names.keys()): + try: + REGISTRY.unregister(collector) + except Exception: + pass + + +def _attributed_failure_kwargs(exception: Exception, rejected_before_routing: bool) -> dict: + return { + "model": "openai/gpt-4.1", + "litellm_params": { + "custom_llm_provider": "openai", + "metadata": {"model_info": {"id": "dep-1"}, "model_group": "internal-model"}, + **({PROXY_REJECTED_BEFORE_ROUTING_KEY: True} if rejected_before_routing else {}), + }, + "standard_logging_object": { + "model_id": "dep-1", + "model_group": "internal-model", + "api_base": "https://api.openai.com", + "metadata": {}, + }, + "exception": exception, + } + + +def _model_id_values(metric) -> set[str]: + index = metric._labelnames.index("model_id") + return {sample_key[index] for sample_key in metric._metrics} + + +class _ProviderError(Exception): + status_code = 500 + + +@pytest.mark.parametrize( + "rejection", + [ + HTTPException(status_code=403, detail="guardrail blocked"), + ProxyException(message="budget exceeded", type="budget_exceeded", param=None, code=400), + ProxyRateLimitError(detail={"error": "key rpm limit"}), + GuardrailRaisedException(guardrail_name="pii", message="blocked", status_code=403), + ], + ids=["http_exception", "proxy_exception", "proxy_rate_limit", "guardrail_raised"], +) +def test_attributed_proxy_reject_leaves_deployment_healthy(rejection: Exception): + logger = PrometheusLogger() + + logger.set_llm_deployment_failure_metrics(_attributed_failure_kwargs(rejection, rejected_before_routing=True)) + + assert logger.litellm_deployment_state._metrics == {} + assert _model_id_values(logger.litellm_deployment_failure_responses) == {""} + assert _model_id_values(logger.litellm_deployment_total_requests) == {""} + + +@pytest.mark.parametrize( + "failure", + [ + _ProviderError("upstream 500"), + GuardrailRaisedException(guardrail_name="pii", message="response blocked", status_code=400), + ], + ids=["provider_error", "post_call_guardrail"], +) +def test_failure_after_routing_still_marks_deployment_partial_outage(failure: Exception): + logger = PrometheusLogger() + + logger.set_llm_deployment_failure_metrics(_attributed_failure_kwargs(failure, rejected_before_routing=False)) + + assert _model_id_values(logger.litellm_deployment_state) == {"dep-1"} + assert _model_id_values(logger.litellm_deployment_failure_responses) == {"dep-1"} diff --git a/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py b/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py index 519a13751f1..f86f4460b28 100644 --- a/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py +++ b/tests/test_litellm/integrations/test_prometheus_requested_model_cardinality.py @@ -116,6 +116,22 @@ async def test_unknown_models_collapse_to_one_series_on_proxy_request_metrics(ro assert _total_value(metric) == 25 +@pytest.mark.asyncio +@pytest.mark.parametrize("model", [["gpt-4o-mini"], {"name": "gpt-4o-mini"}, 123]) +async def test_non_string_models_collapse_to_other_on_proxy_request_metrics(router, model: object): + logger = PrometheusLogger() + + with patch("litellm.proxy.proxy_server.llm_router", router, create=True): # test-quality-ok: production reads proxy_server.llm_router lazily, no injection seam + await logger.async_post_call_failure_hook( + request_data={"model": model, "metadata": {}, "proxy_server_request": {}}, + original_exception=_ClientSideError("'model' must be a string."), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key-1"), + ) + + assert _requested_model_values(logger.litellm_proxy_failed_requests_metric) == {UNRECOGNIZED_REQUESTED_MODEL_LABEL} + assert _total_value(logger.litellm_proxy_failed_requests_metric) == 1 + + @pytest.mark.asyncio async def test_known_alias_and_wildcard_models_keep_their_own_labels(router): logger = PrometheusLogger() diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index f39f41a6d12..d6450d0f1de 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -287,6 +287,162 @@ async def test_execute_search_attributes_spend_to_the_calling_key(monkeypatch): ) +def _perplexity_router() -> MagicMock: + router = MagicMock() + router.search_tools = [ + { + "search_tool_name": "perplexity-sonar-pro", + "litellm_params": {"search_provider": "perplexity", "api_key": "fake-key"}, + } + ] + return router + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "parent_kwargs", + [ + pytest.param( + { + "litellm_call_id": "parent-call-1", + "litellm_trace_id": "trace-abc", + "litellm_session_id": "session-abc", + "metadata": { + "user_api_key_auth": UserAPIKeyAuth(api_key="hashed-sk-1234"), + "session_id": "session-abc", + }, + }, + id="chat-completions-call-kwargs", + ), + pytest.param( + { + "litellm_call_id": "parent-call-1", + "litellm_metadata": { + "user_api_key_auth": UserAPIKeyAuth(api_key="hashed-sk-1234"), + "session_id": "session-abc", + "trace_id": "trace-abc", + }, + }, + id="anthropic-messages-litellm-metadata", + ), + pytest.param( + { + "litellm_params": { + "litellm_call_id": "parent-call-1", + "litellm_trace_id": "trace-abc", + "metadata": {"user_api_key_auth": UserAPIKeyAuth(api_key="hashed-sk-1234")}, + "litellm_metadata": {"session_id": "session-abc"}, + } + }, + id="logging-payload-with-both-metadata-keys", + ), + ], +) +async def test_execute_search_inherits_parent_request_session_and_trace( + monkeypatch: pytest.MonkeyPatch, parent_kwargs: dict[str, object] +): + """The intercepted asearch is billed as its own call but must land in the parent request's + session and trace, otherwise every search shows up as a separate one-call session in SpendLogs.""" + import litellm + from litellm.proxy import proxy_server + from litellm.proxy.spend_tracking.spend_tracking_utils import _get_session_id_for_spend_log + + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="perplexity-sonar-pro") + mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[])) + monkeypatch.setattr(proxy_server, "llm_router", _perplexity_router()) + monkeypatch.setattr(litellm, "asearch", mock_asearch) + + await logger._execute_search("what is litellm", kwargs=parent_kwargs) + + forwarded = mock_asearch.await_args.kwargs + assert forwarded["litellm_session_id"] == "session-abc" + assert forwarded["litellm_trace_id"] == "trace-abc" + assert forwarded["litellm_metadata"]["session_id"] == "session-abc" + assert forwarded["litellm_metadata"]["trace_id"] == "trace-abc" + assert forwarded["litellm_metadata"]["parent_request_id"] == "parent-call-1" + assert forwarded["litellm_metadata"]["user_api_key"] == "hashed-sk-1234" + assert forwarded["litellm_metadata"]["model_group"] == "perplexity-sonar-pro" + assert "litellm_call_id" not in forwarded + assert ( + _get_session_id_for_spend_log( + kwargs={"litellm_trace_id": forwarded["litellm_trace_id"]}, + metadata=forwarded["litellm_metadata"], + standard_logging_payload=None, + omit_when_missing=True, + ) + == "session-abc" + ) + + +@pytest.mark.asyncio +async def test_execute_search_forwards_parent_otel_span_from_key_auth(monkeypatch: pytest.MonkeyPatch): + import litellm + from litellm.proxy import proxy_server + + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="perplexity-sonar-pro") + mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[])) + monkeypatch.setattr(proxy_server, "llm_router", _perplexity_router()) + monkeypatch.setattr(litellm, "asearch", mock_asearch) + parent_span = object() + + await logger._execute_search( + "what is litellm", + kwargs={"metadata": {"user_api_key_auth": UserAPIKeyAuth(api_key="sk", parent_otel_span=parent_span)}}, + ) + + assert mock_asearch.await_args.kwargs["litellm_metadata"]["litellm_parent_otel_span"] is parent_span + + +@pytest.mark.asyncio +async def test_execute_search_without_parent_session_does_not_invent_one(monkeypatch: pytest.MonkeyPatch): + """A parent request with no session/trace must not stamp empty correlation keys on the search.""" + import litellm + from litellm.proxy import proxy_server + + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="perplexity-sonar-pro") + mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[])) + monkeypatch.setattr(proxy_server, "llm_router", _perplexity_router()) + monkeypatch.setattr(litellm, "asearch", mock_asearch) + + await logger._execute_search( + "what is litellm", + kwargs={"litellm_params": {"metadata": {"user_api_key_auth": UserAPIKeyAuth(api_key="sk"), "prompt": "x"}}}, + ) + + forwarded = mock_asearch.await_args.kwargs + assert "litellm_session_id" not in forwarded + assert "litellm_trace_id" not in forwarded + assert not {"session_id", "trace_id", "parent_request_id", "prompt"} & forwarded["litellm_metadata"].keys() + + +@pytest.mark.asyncio +async def test_concurrent_searches_keep_their_own_parent_session(monkeypatch: pytest.MonkeyPatch): + import asyncio + + import litellm + from litellm.proxy import proxy_server + + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="perplexity-sonar-pro") + mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[])) + monkeypatch.setattr(proxy_server, "llm_router", _perplexity_router()) + monkeypatch.setattr(litellm, "asearch", mock_asearch) + + await asyncio.gather( + *( + logger._execute_search( + f"query {i}", + kwargs={"metadata": {"user_api_key_auth": UserAPIKeyAuth(api_key="sk"), "session_id": f"session-{i}"}}, + ) + for i in range(5) + ) + ) + + seen = { + call.kwargs["query"]: call.kwargs["litellm_metadata"]["session_id"] for call in mock_asearch.await_args_list + } + assert seen == {f"query {i}": f"session-{i}" for i in range(5)} + + @pytest.mark.asyncio async def test_execute_search_without_proxy_auth_context_stays_sdk_only(monkeypatch): """SDK callers have no key to attribute the search to, so no proxy metadata is invented.""" diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 4b25c87d70f..76ccdec25d0 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -1,6 +1,7 @@ +import json from collections.abc import Mapping from datetime import datetime, timezone -from typing import cast +from typing import Final, cast import pytest @@ -298,42 +299,6 @@ def test_reasoning_tokens_gemini(_local_model_cost_map): ) -def test_reasoning_tokens_gemini_3_1_flash_lite(_local_model_cost_map): - """Test cost calculation for gemini-3.1-flash-lite-preview with reasoning tokens""" - model = "gemini-3.1-flash-lite-preview" - custom_llm_provider = "gemini" - - usage = Usage( - completion_tokens=1000, - prompt_tokens=500, - total_tokens=1500, - completion_tokens_details=CompletionTokensDetailsWrapper( - accepted_prediction_tokens=None, - audio_tokens=None, - reasoning_tokens=400, - rejected_prediction_tokens=None, - text_tokens=600, - ), - prompt_tokens_details=PromptTokensDetailsWrapper( - audio_tokens=None, cached_tokens=None, text_tokens=500, image_tokens=None - ), - ) - model_cost_map = litellm.model_cost[model] - prompt_cost, completion_cost = generic_cost_per_token( - model=model, - usage=usage, - custom_llm_provider=custom_llm_provider, - ) - - assert round(prompt_cost, 10) == round( - model_cost_map["input_cost_per_token"] * usage.prompt_tokens, - 10, - ) - assert round(completion_cost, 10) == round( - (model_cost_map["output_cost_per_token"] * usage.completion_tokens_details.text_tokens) - + (model_cost_map["output_cost_per_reasoning_token"] * usage.completion_tokens_details.reasoning_tokens), - 10, - ) def test_image_tokens_with_custom_pricing(): @@ -2220,65 +2185,8 @@ def test_vertex_image_generation_cost_falls_back_to_flat_image_pricing(_local_mo assert round(cost, 10) == round(expected_cost, 10) -def test_gemini_image_generation_cost_prefers_token_usage_metadata(_local_model_cost_map): - """ - When usage metadata exists on image responses, Gemini image generation cost - should be calculated from token pricing, not flat output_cost_per_image. - """ - - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - - input_text_tokens = 20 - input_image_tokens = 1120 - output_image_tokens = 1120 - prompt_tokens = input_text_tokens + input_image_tokens - - image_response = ImageResponse( - data=[ImageObject(b64_json="img1"), ImageObject(b64_json="img2")], - usage=ImageUsage( - input_tokens=prompt_tokens, - input_tokens_details=ImageUsageInputTokensDetails( - text_tokens=input_text_tokens, - image_tokens=input_image_tokens, - ), - output_tokens=output_image_tokens, - total_tokens=prompt_tokens + output_image_tokens, - ), - ) - - cost = gemini_image_generation_cost_calculator( - model=model, - image_response=image_response, - ) - - expected_prompt_cost = prompt_tokens * model_info["input_cost_per_token"] - expected_completion_cost = output_image_tokens * model_info["output_cost_per_image_token"] - expected_total_cost = expected_prompt_cost + expected_completion_cost - - assert round(cost, 10) == round(expected_total_cost, 10) - # Ensure this is not falling back to flat per-image pricing. - assert cost != len(image_response.data) * model_info["output_cost_per_image"] -def test_gemini_image_generation_cost_falls_back_to_flat_image_pricing(_local_model_cost_map): - """ - Without usage metadata, Gemini image generation cost should fall back to - output_cost_per_image * number_of_images. - """ - - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - - image_response = ImageResponse(data=[ImageObject(b64_json="img1"), ImageObject(b64_json="img2")]) - - cost = gemini_image_generation_cost_calculator( - model=model, - image_response=image_response, - ) - - expected_cost = len(image_response.data) * model_info["output_cost_per_image"] - assert round(cost, 10) == round(expected_cost, 10) def test_query_count_is_free_without_a_per_query_price(_local_model_cost_map): @@ -2459,23 +2367,6 @@ def test_vertex_global_or_absent_location_no_uplift(vertex_location, _local_mode assert base == located -@pytest.mark.parametrize("model", ["claude-opus-4-1", "gemini-2.0-flash-001"]) -def test_vertex_location_no_uplift_for_uniformly_priced_model(model, _local_model_cost_map): - """Models Google prices uniformly across endpoints (Gemini 2.x, Claude Opus 4.1 - and older) carry no multiplier and must not move with the location.""" - from litellm.types.utils import Usage - - usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) - - base = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="vertex_ai") - regional = generic_cost_per_token( - model=model, - usage=usage, - custom_llm_provider="vertex_ai", - vertex_location="us-east5", - ) - - assert base == regional, f"{model} should not have a regional-endpoint uplift" def test_vertex_uplift_invalid_multiplier_defaults_to_one(): @@ -3637,6 +3528,97 @@ def test_get_token_base_cost_resolves_missing_cache_write_rates_like_the_tiered_ assert creation_1h == pytest.approx(expected_creation_1h) +def _image_response(num_images: int = 1, usage: ImageUsage | None = None) -> ImageResponse: + return ImageResponse( + data=[ImageObject(url="https://example.com/img.png") for _ in range(num_images)], + usage=usage, + ) + + +_GPT_IMAGE_2_HIGH_1024: Final = {"quality": "high", "image_size": {"width": 1024, "height": 1024}} + + +@pytest.mark.parametrize( + ("model", "optional_params", "model_info", "num_images", "expected_cost"), + [ + ("fal-ai/unlisted-image-model", None, {"output_cost_per_image": 0.08}, 1, 0.08), + ("fal-ai/unlisted-image-model", None, {"output_cost_per_image": 0.08}, 2, 0.16), + ("fal-ai/unlisted-image-model", None, {"output_cost_per_image": "0.08"}, 1, 0.08), + ("openai/gpt-image-2", _GPT_IMAGE_2_HIGH_1024, {"output_cost_per_image": 0.5}, 1, 0.5), + ("openai/gpt-image-2", _GPT_IMAGE_2_HIGH_1024, {"mode": "image_generation"}, 1, 0.211), + ("openai/gpt-image-2", _GPT_IMAGE_2_HIGH_1024, {"output_cost_per_image": "0.08 USD"}, 1, 0.211), + ], +) +def test_route_image_generation_cost_honors_deployment_model_info( + _local_model_cost_map: None, + model: str, + optional_params: dict[str, object] | None, + model_info: ModelInfo, + num_images: int, + expected_cost: float, +) -> None: + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model=model, + completion_response=_image_response(num_images), + custom_llm_provider="fal_ai", + optional_params=optional_params, + call_type="image_generation", + model_info=model_info, + ) + + assert cost == pytest.approx(expected_cost) + + +def test_route_image_generation_cost_openai_honors_deployment_input_cost_per_image( + _local_model_cost_map: None, +) -> None: + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model="dall-e-3", + completion_response=_image_response(), + custom_llm_provider="openai", + quality="standard", + size="1024-x-1024", + call_type="image_generation", + model_info={"input_cost_per_image": 0.07}, + ) + + assert cost == pytest.approx(0.07) + + + + +@pytest.mark.parametrize( + ("custom_llm_provider", "model"), + [ + ("gemini", "gemini/unlisted-image-model"), + ("vertex_ai", "vertex_ai/unlisted-image-model"), + ("azure_ai", "unlisted-image-model"), + ("openai", "gpt-image-unlisted"), + ], +) +def test_route_image_generation_cost_bills_deployment_image_price_when_unlisted_model_reports_tokens( + _local_model_cost_map: None, + custom_llm_provider: str, + model: str, +) -> None: + usage = ImageUsage( + input_tokens=10, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=10), + output_tokens=1290, + total_tokens=1300, + ) + + cost = CostCalculatorUtils.route_image_generation_cost_calculator( + model=model, + completion_response=_image_response(num_images=2, usage=usage), + custom_llm_provider=custom_llm_provider, + call_type="image_generation", + model_info={"output_cost_per_image": 0.05}, + ) + + assert cost == pytest.approx(0.10) + + def _batch_rates_model_info(**rates: object) -> ModelInfo: return cast(ModelInfo, dict(rates)) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py index 7bae2eaa338..41a2d19b8ab 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py @@ -109,26 +109,6 @@ def test_get_cost_for_built_in_tools_file_search(): assert cost == 0.00 -def test_get_cost_for_anthropic_web_search(): - """ - Test that Anthropic web search cost is tracked when usage.server_tool_use.web_search_requests - is set. Use claude-3-7-sonnet-20250219 (has search_context_cost_per_query) and - custom_llm_provider=anthropic so get_cost_for_anthropic_web_search is invoked. - """ - from litellm.types.utils import ServerToolUse, Usage - - model = "claude-3-7-sonnet-20250219" - usage = Usage(server_tool_use=ServerToolUse(web_search_requests=1)) - cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( - model=model, - usage=usage, - response_object=None, - standard_built_in_tools_params=None, - custom_llm_provider="anthropic", - ) - assert cost > 0.0 - - def test_get_cost_for_anthropic_web_search_with_server_tool_use_dict(): """ Anthropic-compatible passthrough responses can construct Usage from a raw @@ -145,88 +125,6 @@ def test_get_cost_for_anthropic_web_search_with_server_tool_use_dict(): ) -def test_anthropic_web_search_cost_from_raw_response_dict_when_usage_drops_server_tool_use(): - """ - Regression: on the Anthropic /v1/messages sync cost path the response is the raw - Anthropic dict while the reconstructed OpenAI-shape Usage drops server_tool_use. - The web-search fee must still be charged by reading the count off the raw dict, - and the passed-in Usage must not be mutated. - """ - from litellm.types.utils import Usage - - model = "claude-3-7-sonnet-20250219" - web_search_requests = 3 - raw_response = { - "id": "msg_1", - "type": "message", - "role": "assistant", - "model": model, - "content": [{"type": "text", "text": "hi"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "server_tool_use": {"web_search_requests": web_search_requests}, - }, - } - usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) - assert getattr(usage, "server_tool_use", None) is None - - cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( - model=model, - usage=usage, - response_object=raw_response, - custom_llm_provider="anthropic", - standard_built_in_tools_params=None, - ) - - per_query_cost = litellm.get_model_info(model)["search_context_cost_per_query"][ - "search_context_size_medium" - ] - assert cost == per_query_cost * web_search_requests - assert cost > 0.0 - assert getattr(usage, "server_tool_use", None) is None - - -def test_anthropic_web_search_cost_from_raw_response_dict_when_usage_is_none(): - """ - Regression: when a caller hands the cost tracker a raw Anthropic dict without a - parallel Usage object, the web-search fee must still be priced per request from - usage.server_tool_use.web_search_requests on the dict instead of falling back to - the flat search_context_size_medium tier. - """ - model = "claude-3-7-sonnet-20250219" - web_search_requests = 4 - raw_response = { - "id": "msg_1", - "type": "message", - "role": "assistant", - "model": model, - "content": [{"type": "text", "text": "hi"}], - "stop_reason": "end_turn", - "stop_sequence": None, - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "server_tool_use": {"web_search_requests": web_search_requests}, - }, - } - - cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( - model=model, - usage=None, - response_object=raw_response, - custom_llm_provider="anthropic", - standard_built_in_tools_params=None, - ) - - per_query_cost = litellm.get_model_info(model)["search_context_cost_per_query"][ - "search_context_size_medium" - ] - assert cost == per_query_cost * web_search_requests - - def test_anthropic_web_search_zero_requests_from_raw_response_charges_zero(): """ Regression: a raw Anthropic dict reporting zero web search requests must price @@ -287,27 +185,6 @@ def test_anthropic_response_usage_block_preserves_server_tool_use(): assert dumped_usage["server_tool_use"] == {"web_search_requests": 2} -@pytest.mark.parametrize( - "model", ["gemini/gemini-2.0-flash-001", "gemini-2.0-flash-001"] -) -def test_get_cost_for_gemini_web_search(model): - """ - Test that the cost for a web search is 0.00 when no response object is provided - """ - from litellm.types.utils import PromptTokensDetailsWrapper, Usage - - usage = Usage( - prompt_tokens_details=PromptTokensDetailsWrapper(web_search_requests=1) - ) - cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( - model=model, - usage=usage, - response_object=None, - standard_built_in_tools_params=None, - ) - assert cost > 0.0 - - def test_completion_cost_includes_web_search_without_standard_built_in_tools_params(): """ Test that completion_cost includes web search cost even when diff --git a/tests/test_litellm/litellm_core_utils/test_bug_report.py b/tests/test_litellm/litellm_core_utils/test_bug_report.py index f47361dda2e..62d7090b960 100644 --- a/tests/test_litellm/litellm_core_utils/test_bug_report.py +++ b/tests/test_litellm/litellm_core_utils/test_bug_report.py @@ -21,6 +21,7 @@ from litellm.litellm_core_utils.bug_report import ( bug_report_issue_url, bug_report_notice, build_bug_report, + build_environment_report, should_report_bug, strip_bug_report_notice, ) @@ -202,3 +203,28 @@ def test_oversized_config_is_trimmed_from_the_end_before_any_frame(): assert all(frame in description for frame in report.litellm_frames) assert "general_settings.flag_0000 = true" in description assert "general_settings.flag_0399 = true" not in description + + +def test_issue_url_carries_exactly_the_environment_report_fields(): + report = build_bug_report( + RuntimeError("boom"), + surface="proxy", + config_lines=("litellm_settings.drop_params = true",), + ) + environment = report.environment + query = parse_qs(urlparse(bug_report_issue_url(report)).query) + description = query["description"][0] + + assert environment == build_environment_report( + surface="proxy", config_lines=("litellm_settings.drop_params = true",) + ) + assert query["version"] == [environment.litellm_version] + assert f"Surface: {environment.surface}\n" in description + assert f"LiteLLM: {environment.litellm_version}\n" in description + assert f"Python: {environment.python_version}\n" in description + assert "\nlitellm_settings.drop_params = true\n" in description + assert query.get("deployment") == (None if environment.deployment is None else [environment.deployment]) + + +def test_sdk_environment_reports_the_pip_deployment(): + assert build_environment_report(surface="sdk").deployment == "pip / Python SDK" diff --git a/tests/test_litellm/litellm_core_utils/test_core_helpers.py b/tests/test_litellm/litellm_core_utils/test_core_helpers.py index bca61a0e76f..6eeea271127 100644 --- a/tests/test_litellm/litellm_core_utils/test_core_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_core_helpers.py @@ -322,6 +322,28 @@ class TestRedactNestedMatchAndRegexKeys: assert redact_nested_match_and_regex_keys(None) is None assert redact_nested_match_and_regex_keys("plain") == "plain" + def test_redacts_custom_keys_without_changing_default_keys(self): + payload = { + "keyword": "secret-keyword", + "snippet": "secret-snippet", + "match": "secret-match", + "regex": "secret-regex", + "nested": [{"keyword": "nested-keyword", "match": "nested-match"}], + } + + custom_keys = redact_nested_match_and_regex_keys(payload, keys=("keyword", "snippet")) + default_keys = redact_nested_match_and_regex_keys(payload) + + assert custom_keys["keyword"] == "[REDACTED]" + assert custom_keys["snippet"] == "[REDACTED]" + assert custom_keys["nested"][0]["keyword"] == "[REDACTED]" + assert custom_keys["match"] == "secret-match" + assert custom_keys["regex"] == "secret-regex" + assert default_keys["match"] == "[REDACTED]" + assert default_keys["regex"] == "[REDACTED]" + assert default_keys["keyword"] == "secret-keyword" + assert default_keys["snippet"] == "secret-snippet" + @pytest.mark.parametrize( "value, expected", diff --git a/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py b/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py index 7928768b3bd..f70b52a7026 100644 --- a/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py +++ b/tests/test_litellm/litellm_core_utils/test_fallback_generalizations.py @@ -979,10 +979,6 @@ def test_shipped_tool_search_rule_fills_mapped_claude_entries_without_flag(shipp assert "supports_tool_search" not in litellm.model_cost[key] assert litellm.get_model_info(model, custom_llm_provider=provider)["supports_tool_search"] is True - assert "supports_tool_search" not in litellm.model_cost["claude-opus-4-1"] - opus_4_1_info = litellm.get_model_info("claude-opus-4-1", custom_llm_provider="anthropic") - assert opus_4_1_info.get("supports_tool_search") is None - assert "supports_tool_search" not in litellm.model_cost["azure_ai/claude-opus-5"] azure_opus_5_info = litellm.get_model_info("claude-opus-5", custom_llm_provider="azure_ai") assert azure_opus_5_info.get("supports_tool_search") is None diff --git a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py index 4c963d14ada..7fb45e1b092 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py +++ b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py @@ -4,6 +4,8 @@ Tests for get_litellm_params and related helpers. Ensures backward compatibility after sparse kwargs extraction optimization. """ +from typing import Final + import pytest from litellm.litellm_core_utils.get_litellm_params import ( @@ -12,6 +14,10 @@ from litellm.litellm_core_utils.get_litellm_params import ( get_litellm_params, ) +NAMED_PRICE_PARAMS: Final = frozenset( + {"input_cost_per_token", "output_cost_per_token", "input_cost_per_second", "output_cost_per_second"} +) + class TestGetBaseModelFromLitellmCallMetadata: def test_none_metadata_returns_none(self): @@ -40,10 +46,27 @@ class TestGetLitellmParamsKwargsExtraction: """Verify that optional kwargs are correctly extracted via sparse extraction.""" def test_no_kwargs_omits_optional_keys(self): - """When no kwargs passed, optional keys should not be in result.""" + """When no kwargs passed, optional keys are absent; the named price params are present as None.""" result = get_litellm_params(api_key="test-key") - for key in _OPTIONAL_KWARGS_KEYS: + for key in _OPTIONAL_KWARGS_KEYS - NAMED_PRICE_PARAMS: assert key not in result + for key in NAMED_PRICE_PARAMS: + assert result[key] is None + + def test_custom_pricing_kwargs_are_extracted(self) -> None: + from litellm.litellm_core_utils.litellm_logging import use_custom_pricing_for_model + from litellm.types.router import CustomPricingLiteLLMParams + + assert set(CustomPricingLiteLLMParams.model_fields) <= _OPTIONAL_KWARGS_KEYS + + result = get_litellm_params(output_cost_per_image=0.08, input_cost_per_audio_token=1e-6) + assert result["output_cost_per_image"] == 0.08 + assert result["input_cost_per_audio_token"] == 1e-6 + assert use_custom_pricing_for_model(result) is True + + result_without_prices = get_litellm_params() + assert "output_cost_per_image" not in result_without_prices + assert use_custom_pricing_for_model(result_without_prices) is False def test_present_kwargs_are_extracted(self): result = get_litellm_params( diff --git a/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py b/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py index 1ecef9ffff7..ed8438bb1de 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py +++ b/tests/test_litellm/litellm_core_utils/test_get_llm_provider_logic.py @@ -1,5 +1,6 @@ from typing import Final +import httpx import pytest import litellm @@ -8,6 +9,7 @@ from litellm.litellm_core_utils.get_llm_provider_logic import ( get_llm_provider, is_registered_custom_provider, ) +from litellm.llms.custom_httpx.http_handler import HTTPHandler CUSTOM_PROVIDER: Final = "test-onprem-llm" @@ -53,3 +55,26 @@ def test_get_llm_provider_still_rejects_unregistered_prefix(registered_custom_pr ) def test_is_registered_custom_provider(registered_custom_provider: str, candidate: str | None, expected: bool) -> None: assert is_registered_custom_provider(candidate) is expected + + +def test_get_llm_provider_leaves_fal_ai_api_base_unset_for_global_fallback() -> None: + _, provider, _, api_base = get_llm_provider(model="fal_ai/fal-ai/flux/schnell") + assert provider == "fal_ai" + assert api_base is None + + _, _, _, explicit = get_llm_provider(model="fal_ai/fal-ai/flux/schnell", api_base="http://edge.local/fal") + assert explicit == "http://edge.local/fal" + + +def test_image_generation_fal_ai_egresses_to_global_api_base(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "api_base", "http://gateway.local/fal") + monkeypatch.setenv("FAL_AI_API_KEY", "test") + seen: Final[list[httpx.URL]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url) + return httpx.Response(200, json={"images": [{"url": "https://fal.media/a.png"}]}) + + client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))) + litellm.image_generation(model="fal_ai/fal-ai/flux/schnell", prompt="a red kite", client=client) + assert str(seen[0]).startswith("http://gateway.local/fal") diff --git a/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py b/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py index 262dabb7c1b..00977d9c3ee 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py +++ b/tests/test_litellm/litellm_core_utils/test_get_model_cost_map.py @@ -218,7 +218,6 @@ def test_shipped_backup_marks_claude_4_6_plus_adaptive_not_4_0(): assert backup[adaptive]["supports_adaptive_thinking"] is True, adaptive for non_adaptive in [ - "claude-opus-4-20250514", "us.anthropic.claude-opus-4-20250514-v1:0", "claude-opus-4-5", ]: diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 8fa4bd6c14d..21e97e97357 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -427,7 +427,7 @@ async def test_async_inline_remote_media_cancels_the_other_fetches_when_one_fail _SSRF_VERDICTS = ( SSRFError( "URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, " - "add the host to `user_url_allowed_hosts` in general_settings." + "add the host to `user_url_allowed_hosts` in litellm_settings." ), SSRFError("DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known"), SSRFError("No addresses found for 'internal.example'"), diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index b0fc725b50a..5bdc0e9f8b8 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -5,16 +5,15 @@ import json import logging import os import sys +import time from collections.abc import Callable, Iterator, Mapping from types import MappingProxyType from typing import Final, Literal from unittest.mock import AsyncMock, MagicMock, patch -import pytest - -import time - import httpx +import pytest +from mcp.types import AudioContent, CallToolResult, ImageContent, TextContent from openai._legacy_response import HttpxBinaryResponseContent import litellm @@ -51,6 +50,272 @@ def logging_obj(): ) +@pytest.mark.asyncio +async def test_async_post_mcp_tool_call_hook_preserves_and_returns_content(logging_obj): + from litellm.types.mcp import MCPPostCallResponseObject + + class RedactingLogger(CustomLogger): + async def async_post_mcp_tool_call_hook( + self, + kwargs: dict[str, object], + response_obj: MCPPostCallResponseObject, + start_time: datetime.datetime, + end_time: datetime.datetime, + ) -> MCPPostCallResponseObject: + assert isinstance(response_obj.mcp_tool_call_response, list) + assert isinstance(response_obj.mcp_tool_call_response[0], TextContent) + response_obj.mcp_tool_call_response = [TextContent(type="text", text="[REDACTED]")] + return response_obj + + logging_obj.dynamic_success_callbacks = [RedactingLogger()] + result = CallToolResult(content=[TextContent(type="text", text="SECRET-1234")], isError=False) + + hooked_content = await logging_obj.async_post_mcp_tool_call_hook( + kwargs=logging_obj.model_call_details, + response_obj=result, + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + + assert hooked_content.content == [TextContent(type="text", text="[REDACTED]")] + + +@pytest.mark.asyncio +async def test_async_post_mcp_tool_call_hook_chains_every_callback(logging_obj): + from litellm.types.mcp import MCPPostCallResponseObject + + class ReplacingLogger(CustomLogger): + def __init__(self, old: str, new: str) -> None: + super().__init__() + self.old: Final = old + self.new: Final = new + self.seen: list[str] = [] # mutable-ok: test records what each callback observed + + async def async_post_mcp_tool_call_hook( + self, + kwargs: dict[str, object], + response_obj: MCPPostCallResponseObject, + start_time: datetime.datetime, + end_time: datetime.datetime, + ) -> MCPPostCallResponseObject: + first = response_obj.mcp_tool_call_response[0] + assert isinstance(first, TextContent) + self.seen.append(first.text) + return MCPPostCallResponseObject( + mcp_tool_call_response=[TextContent(type="text", text=first.text.replace(self.old, self.new))], + hidden_params=response_obj.hidden_params, + ) + + first_logger: Final = ReplacingLogger("SECRET", "[S]") + second_logger: Final = ReplacingLogger("1234", "[N]") + logging_obj.dynamic_success_callbacks = [first_logger, second_logger] + result = CallToolResult(content=[TextContent(type="text", text="SECRET-1234")], isError=False) + + hooked_content = await logging_obj.async_post_mcp_tool_call_hook( + kwargs=logging_obj.model_call_details, + response_obj=result, + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + + assert first_logger.seen == ["SECRET-1234"] + assert second_logger.seen == ["[S]-1234"] + assert hooked_content.content == [TextContent(type="text", text="[S]-[N]")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["replace", "inplace", "empty", "inplace_none", "replace_none"]) +@pytest.mark.parametrize("structured", [False, True]) +async def test_mcp_content_rewrite_never_returns_stale_structured_data(logging_obj, mode, structured): + from litellm.types.llms.base import HiddenParams + from litellm.types.mcp import MCPPostCallResponseObject + + class Redactor(CustomLogger): + async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time): + block = response_obj.mcp_tool_call_response[0] + assert isinstance(block, TextContent) + if mode in ("inplace", "inplace_none"): + block.text = "[REDACTED]" + return None if mode == "inplace_none" else response_obj + if mode == "replace_none": + response_obj.mcp_tool_call_response = [TextContent(type="text", text="[REDACTED]")] + return None + return MCPPostCallResponseObject( + mcp_tool_call_response=[] if mode == "empty" else [TextContent(type="text", text="[REDACTED]")], + hidden_params=HiddenParams(response_cost=0.25), + ) + + logging_obj.dynamic_success_callbacks = [Redactor()] + result = CallToolResult( + content=[TextContent(type="text", text="SECRET-1234")], + structured_content={"nested": {"secret": "SECRET-1234"}} if structured else None, + meta={"request": "trace-1"}, + ) + logging_obj.model_call_details["original_response"] = result + returned = await logging_obj.async_post_mcp_tool_call_hook( + kwargs=logging_obj.model_call_details, + response_obj=result, + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + assert "SECRET-1234" not in result.model_dump_json(by_alias=True) + assert returned is result + assert result.content == ([] if mode == "empty" else [TextContent(type="text", text="[REDACTED]")]) + assert result.structured_content is None + assert result.is_error is structured + assert result.meta == {"request": "trace-1"} + assert logging_obj.model_call_details["original_response"] is result + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["none", "cost", "direct", "block", "exception"]) +async def test_mcp_callbacks_preserve_effective_result_and_cost(logging_obj, mode): + from litellm.types.llms.base import HiddenParams + from litellm.types.mcp import MCPPostCallResponseObject + + class Callback(CustomLogger): + async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time): + if mode == "exception": + response_obj.mcp_tool_call_response[0].text = "discarded" + raise ValueError("non-blocking callback") + if mode in ("direct", "block"): + original = kwargs["original_response"] + original.content = [TextContent(type="text", text="safe")] + original.structured_content = {"result": "safe"} + original.is_error = mode == "block" + if mode == "none" or mode == "direct": + return None + return MCPPostCallResponseObject( + mcp_tool_call_response=response_obj.mcp_tool_call_response, + hidden_params=HiddenParams(response_cost=0.25), + ) + + logging_obj.dynamic_success_callbacks = [Callback()] + result = CallToolResult( + content=[TextContent(type="text", text="original")], structured_content={"result": "original"} + ) + logging_obj.model_call_details["original_response"] = result + returned = await logging_obj.async_post_mcp_tool_call_hook( + kwargs=logging_obj.model_call_details, + response_obj=result, + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + expected = "safe" if mode in ("direct", "block") else "original" + assert result.content == [TextContent(type="text", text=expected)] + assert result.structured_content == {"result": expected} + assert result.is_error is (mode == "block") + assert returned is result + assert logging_obj.model_call_details.get("response_cost") == (0.25 if mode in ("cost", "block") else None) + + +@pytest.mark.asyncio +async def test_mcp_callback_cancellation_propagates_without_mutating_result(logging_obj): + class CancelledCallback(CustomLogger): + async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time): + response_obj.mcp_tool_call_response[0].text = "partial" + raise asyncio.CancelledError + + logging_obj.dynamic_success_callbacks = [CancelledCallback()] + result = CallToolResult(content=[TextContent(type="text", text="original")]) + with pytest.raises(asyncio.CancelledError): + await logging_obj.async_post_mcp_tool_call_hook( + kwargs={}, + response_obj=result, + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + assert result.content == [TextContent(type="text", text="original")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("callbacks", [[], ["prometheus"]]) +@pytest.mark.parametrize("is_error", [False, True]) +async def test_mcp_without_custom_callbacks_preserves_mixed_content(logging_obj, callbacks, is_error): + logging_obj.dynamic_success_callbacks = callbacks + result = CallToolResult( + content=[ + TextContent(type="text", text="ok"), + ImageContent(type="image", data="aW1n", mime_type="image/png"), + AudioContent(type="audio", data="c291bmQ=", mime_type="audio/wav"), + ], + structured_content={"result": "ok"}, + is_error=is_error, + ) + before = result.model_dump() + returned = await logging_obj.async_post_mcp_tool_call_hook( + kwargs={}, + response_obj=result, + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + assert returned is result + assert returned.model_dump() == before + + +@pytest.mark.asyncio +@pytest.mark.parametrize("replace_structured", [False, True]) +@pytest.mark.parametrize("same_content", [False, True]) +async def test_mcp_native_structured_replacement_must_match_returned_content( + logging_obj, replace_structured, same_content +): + from litellm.types.mcp import MCPPostCallResponseObject + + class NativeReplacement(CustomLogger): + async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time): + original = kwargs["original_response"] + original.content[0].text = "native-safe" + if replace_structured: + original.structured_content["result"] = "native-safe" + return MCPPostCallResponseObject( + mcp_tool_call_response=[TextContent(type="text", text="native-safe" if same_content else "final-safe")], + hidden_params=response_obj.hidden_params, + ) + + result = CallToolResult( + content=[TextContent(type="text", text="SECRET-1234")], + structured_content={"result": "SECRET-1234"}, + ) + logging_obj.dynamic_success_callbacks = [NativeReplacement()] + returned = await logging_obj.async_post_mcp_tool_call_hook( + kwargs={"original_response": result}, response_obj=result, + start_time=datetime.datetime.now(), end_time=datetime.datetime.now(), + ) + assert returned is result + assert result.content == [TextContent(type="text", text="native-safe" if same_content else "final-safe")] + assert result.structured_content == ({"result": "native-safe"} if replace_structured and same_content else None) + assert result.is_error is not (replace_structured and same_content) + assert "SECRET-1234" not in result.model_dump_json() + + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["none", "wrapper", "exception"]) +@pytest.mark.parametrize("structured", [False, True]) +async def test_mcp_direct_content_edit_invalidates_stale_structured_data(logging_obj, mode, structured): + class DirectRedactor(CustomLogger): + async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time): + kwargs["original_response"].content[0].text = "[REDACTED]" + if mode == "exception": + raise ValueError("non-blocking callback after direct edit") + return response_obj if mode == "wrapper" else None + + result = CallToolResult( + content=[TextContent(type="text", text="SECRET-1234")], + structured_content={"result": "SECRET-1234"} if structured else None, + ) + logging_obj.dynamic_success_callbacks = [DirectRedactor()] + returned = await logging_obj.async_post_mcp_tool_call_hook( + kwargs={"original_response": result}, response_obj=result, + start_time=datetime.datetime.now(), end_time=datetime.datetime.now(), + ) + assert returned is result + assert result.content == [TextContent(type="text", text="[REDACTED]")] + assert result.structured_content is None + assert result.is_error is structured + assert "SECRET-1234" not in result.model_dump_json() + + def test_get_combined_callback_list_preserves_insertion_order(logging_obj): assert logging_obj.get_combined_callback_list( dynamic_success_callbacks=["prometheus", "langfuse", "datadog", "otel", "s3"], @@ -8124,21 +8389,6 @@ def test_get_assembled_streaming_response_bills_a_provider_reported_usage_cost() assert logging_obj._response_cost_calculator(result=assembled) == 0.0042 -def test_get_assembled_streaming_response_without_usage_cost_leaves_pricing_to_the_price_map(): - logging_obj = _responses_stream_logging_obj() - now = datetime.datetime.now() - - assembled = logging_obj._get_assembled_streaming_response( - result=_completed_responses_event(ResponseAPIUsage(input_tokens=12, output_tokens=2, total_tokens=14)), - start_time=now, - end_time=now, - is_async=True, - streaming_chunks=[], - ) - - assert "additional_headers" not in assembled._hidden_params - price_map_cost = logging_obj._response_cost_calculator(result=assembled) - assert price_map_cost is not None and 0 < price_map_cost != 0.0042 def test_response_cost_calculator_prices_terminal_responses_event_from_its_response(): diff --git a/tests/test_litellm/litellm_core_utils/test_provider_affinity.py b/tests/test_litellm/litellm_core_utils/test_provider_affinity.py new file mode 100644 index 00000000000..edf4f5169b5 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_provider_affinity.py @@ -0,0 +1,107 @@ +import pytest + +from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY +from litellm.litellm_core_utils.provider_affinity import ( + add_provider_affinity_header, + get_stable_session_id, +) + + +@pytest.mark.parametrize( + ("litellm_params", "expected"), + [ + ({"litellm_session_id": "litellm-session"}, "litellm-session"), + ({"session_id": "direct-session"}, "direct-session"), + ({"metadata": {"session_id": "metadata-session"}}, "metadata-session"), + ({"litellm_metadata": {"session_id": "litellm-metadata-session"}}, "litellm-metadata-session"), + ], +) +def test_get_stable_session_id_uses_explicit_session_sources(litellm_params: dict, expected: str): + assert get_stable_session_id(litellm_params) == expected + + +def test_get_stable_session_id_does_not_use_trace_id(): + assert get_stable_session_id({"litellm_trace_id": "per-request-trace"}) is None + + +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +def test_get_stable_session_id_ignores_proxy_generated_session(metadata_key: str): + assert ( + get_stable_session_id( + { + "litellm_session_id": "generated-session", + metadata_key: { + "session_id": "generated-session", + SESSION_ID_GENERATED_METADATA_KEY: True, + }, + } + ) + is None + ) + + +def test_get_stable_session_id_prefers_explicit_session_over_proxy_generated_session(): + assert ( + get_stable_session_id( + { + "session_id": "explicit-session", + "litellm_session_id": "generated-session", + "metadata": { + "session_id": "generated-session", + SESSION_ID_GENERATED_METADATA_KEY: True, + }, + } + ) + == "explicit-session" + ) + + +def test_add_provider_affinity_header_maps_session_id(): + headers = add_provider_affinity_header( + headers={"Content-Type": "application/json"}, + litellm_params={ + "litellm_session_id": "session-123", + "provider_affinity_header": "X-Conversation-Id", + }, + ) + + assert headers == { + "Content-Type": "application/json", + "X-Conversation-Id": "session-123", + } + + +def test_add_provider_affinity_header_preserves_explicit_header_case_insensitively(): + headers = add_provider_affinity_header( + headers={"x-conversation-id": "explicit-session"}, + litellm_params={ + "litellm_session_id": "session-123", + "provider_affinity_header": "X-Conversation-Id", + }, + ) + + assert headers == {"x-conversation-id": "explicit-session"} + + +@pytest.mark.parametrize("session_id", ["session\r", "session\n", "session\0"]) +def test_add_provider_affinity_header_rejects_control_characters(session_id: str): + with pytest.raises(ValueError, match="session_id cannot contain HTTP header control characters"): + add_provider_affinity_header( + headers={}, + litellm_params={ + "litellm_session_id": session_id, + "provider_affinity_header": "X-Conversation-Id", + }, + ) + + +def test_add_provider_affinity_header_does_nothing_without_config_or_session(): + assert add_provider_affinity_header({}, {"litellm_session_id": "session-123"}) == {} + assert ( + add_provider_affinity_header( + {}, + {"provider_affinity_header": "X-Conversation-Id"}, + ) + == {} + ) + diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_errors.py b/tests/test_litellm/litellm_core_utils/test_realtime_errors.py index 1d2cf905f4e..999c660c286 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_errors.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_errors.py @@ -1,13 +1,17 @@ import json +from typing import cast import pytest from litellm.litellm_core_utils.realtime_errors import ( WEBSOCKET_CLOSE_REASON_MAX_BYTES, client_close_code, + close_after_upstream_handshake_refusal, realtime_error_event, + upstream_handshake_close_code, websocket_close_reason, ) +from litellm.types.realtime import RealtimeErrorEvent def test_realtime_error_event_shape(): @@ -52,3 +56,52 @@ def test_websocket_close_reason_truncates_multibyte_message_by_bytes(): ) def test_client_close_code_only_forwards_codes_a_server_may_send(upstream_code, expected): assert client_close_code(upstream_code) == expected + + +@pytest.mark.parametrize( + ("status_code", "expected"), + [(401, 1008), (403, 1008), (429, 1013), (500, 1011)], +) +def test_upstream_handshake_close_code_maps_http_status_to_close_code(status_code: int, expected: int): + assert upstream_handshake_close_code(status_code) == expected + + +class _RecordingWebSocket: + def __init__(self) -> None: + self.sent: list[str] = [] + self.closed: tuple[int, str | None] | None = None + + async def send_text(self, data: str) -> None: + self.sent.append(data) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + self.closed = (code, reason) + + +@pytest.mark.asyncio +async def test_close_after_upstream_handshake_refusal_sends_error_event_then_policy_close(): + websocket = _RecordingWebSocket() + + await close_after_upstream_handshake_refusal(websocket, 401) + + assert len(websocket.sent) == 1 + event = cast(RealtimeErrorEvent, json.loads(websocket.sent[0])) + assert event["type"] == "error" + assert event["error"]["type"] == "server_error" + assert "401" in event["error"]["message"] + assert websocket.closed is not None + assert websocket.closed[0] == 1008 + assert websocket.closed[1] + + +@pytest.mark.asyncio +async def test_close_after_upstream_handshake_refusal_still_closes_when_send_fails(): + class _DeadWebSocket(_RecordingWebSocket): + async def send_text(self, data: str) -> None: + raise RuntimeError("socket gone") + + websocket = _DeadWebSocket() + + await close_after_upstream_handshake_refusal(websocket, 500) + + assert websocket.closed == (1011, "Upstream realtime handshake rejected with HTTP 500") diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_server_tool_use.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_server_tool_use.py index 75508917a1e..e8a7ef74bfa 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_server_tool_use.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_server_tool_use.py @@ -99,29 +99,3 @@ def test_stream_chunk_builder_coerces_server_tool_use_to_pydantic(): assert server_tool_use.web_search_requests == 3 -def test_completion_cost_does_not_raise_on_streaming_web_search_response(): - """ - Regression: completion_cost(...) must not raise AttributeError when the - response was reconstructed by stream_chunk_builder from a streaming - Anthropic web_search call. - """ - chunks = [ - _make_text_chunk("hello"), - _make_finish_chunk_with_usage_dict_server_tool_use(), - ] - - rebuilt = stream_chunk_builder(chunks) - assert rebuilt is not None - - # The exact dollar amount depends on the model-pricing table; what matters - # for this regression is that it does NOT raise AttributeError on - # `dict has no attribute 'web_search_requests'`. - try: - cost = completion_cost(completion_response=rebuilt) - except AttributeError as e: # pragma: no cover - regression guard - pytest.fail( - "completion_cost raised AttributeError after stream_chunk_builder " - f"(issue #26153 regression): {e}" - ) - - assert isinstance(cost, (int, float)) diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py index 5d75c6699cf..af763da2d87 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -1,5 +1,6 @@ from collections.abc import Mapping, Sequence from typing import Final +from unittest.mock import MagicMock import pytest @@ -7,6 +8,7 @@ import pytest from litellm import ChatCompletionUsageBlock, stream_chunk_builder from litellm.types.utils import GenericStreamingChunk from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor +from litellm.llms.anthropic.chat.handler import ModelResponseIterator from litellm.types.utils import ( ChatCompletionDeltaToolCall, ChatCompletionMessageToolCall, @@ -1650,6 +1652,71 @@ def test_calculate_usage_falls_back_to_prompt_counter_when_mock_stream_has_no_ad assert usage.prompt_tokens == 77 +@pytest.mark.parametrize( + ("message_delta_usage", "expected_cache_creation", "expected_cache_read"), + [ + ( + { + "input_tokens": 2, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 58352, + "output_tokens": 408, + }, + 0, + 58352, + ), + ({"output_tokens": 408}, 58352, 0), + ({"input_tokens": 2, "output_tokens": 408}, 58352, 0), + ], + ids=["delta_restates_cache_counts", "delta_reports_output_only", "delta_reports_input_and_output_only"], +) +def test_anthropic_stream_usage_takes_cache_counts_from_last_event_that_reports_them( + message_delta_usage: Mapping[str, int], expected_cache_creation: int, expected_cache_read: int +) -> None: + iterator: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False) + events: Final = ( + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [], + "stop_reason": None, + "usage": { + "input_tokens": 2, + "cache_creation_input_tokens": 58352, + "cache_read_input_tokens": 0, + "output_tokens": 1, + }, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": dict(message_delta_usage)}, + {"type": "message_stop"}, + ) + + response: Final = stream_chunk_builder( + chunks=[iterator.chunk_parser(event) for event in events], + messages=[{"role": "user", "content": "hi"}], + ) + + assert response.usage.cache_creation_input_tokens == expected_cache_creation + assert response.usage.cache_read_input_tokens == expected_cache_read + assert response.usage.prompt_tokens == 58354 + assert response.usage.prompt_tokens_details.cache_creation_tokens == expected_cache_creation + assert response.usage.prompt_tokens_details.cached_tokens == expected_cache_read + assert ( + response.usage.prompt_tokens + - response.usage.cache_read_input_tokens + - response.usage.cache_creation_input_tokens + == 2 + ) + + _ZERO_USAGE_TEXT_CHUNKS: Final = ( _openai_chunk(choices=[{"index": 0, "delta": {"role": "assistant", "content": "Hi"}, "finish_reason": None}]), _openai_chunk(choices=[{"index": 0, "delta": {"content": " there"}, "finish_reason": None}]), diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index 5ce6a4b1ce9..eccf44a1bda 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -633,9 +633,6 @@ def test_openai_token_with_image_and_text(): "model, base_model, input_tokens, user_max_tokens, expected_value", [ ("random-model", "random-model", 1024, 1024, 1024), - ("command", "command", 1000000, None, None), # model max = 4096 - ("command", "command", 4000, 256, 96), # model max = 4096 - ("command", "command", 4000, 10, 10), # model max = 4096 ("gpt-3.5-turbo", "gpt-3.5-turbo", 4000, 5000, 4096), # model max output = 4096 ], ) diff --git a/tests/test_litellm/llms/azure/realtime/__init__.py b/tests/test_litellm/llms/azure/realtime/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/test_litellm/llms/azure/realtime/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_litellm/llms/azure/realtime/test_handler.py b/tests/test_litellm/llms/azure/realtime/test_handler.py new file mode 100644 index 00000000000..edf1b8b290f --- /dev/null +++ b/tests/test_litellm/llms/azure/realtime/test_handler.py @@ -0,0 +1,86 @@ +import json +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest + + +class _RecordingClientWebSocket: + scope: dict[str, list[tuple[bytes, bytes]]] = {"headers": []} + + def __init__(self) -> None: + self.sent: list[str] = [] + self.closed: list[tuple[int, str | None]] = [] + + async def send_text(self, data: str) -> None: + self.sent.append(data) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + self.closed.append((code, reason)) + + +@pytest.mark.asyncio +async def test_async_realtime_upstream_handshake_refusal_sends_error_event_then_policy_close(): + import websockets + + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + from litellm.types.realtime import RealtimeErrorEvent + + handler = AzureOpenAIRealtime() + model = "gpt-realtime" + + dummy_websocket = _RecordingClientWebSocket() + dummy_logging_obj = MagicMock() + + refused = websockets.exceptions.InvalidStatus( + websockets.http11.Response(401, "Unauthorized", websockets.datastructures.Headers()) + ) + + with patch("websockets.connect", side_effect=refused): + await handler.async_realtime( # pyright: ignore[reportUnknownMemberType] # handler's websocket param is a Protocol here but the mock connect type is incomplete + model=model, + websocket=dummy_websocket, + logging_obj=dummy_logging_obj, + api_base="https://example.openai.azure.com", + api_key="bad-key", + api_version="2025-08-28", + query_params={"model": model}, + ) + + assert len(dummy_websocket.sent) == 1 + event = cast(RealtimeErrorEvent, json.loads(dummy_websocket.sent[0])) + assert event["type"] == "error" + assert event["error"]["type"] == "server_error" + assert "401" in event["error"]["message"] + assert dummy_websocket.closed and dummy_websocket.closed[0][0] == 1008 + + +@pytest.mark.asyncio +async def test_async_realtime_unexpected_error_sends_error_event_then_internal_close(): + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + from litellm.types.realtime import RealtimeErrorEvent + + handler = AzureOpenAIRealtime() + model = "gpt-realtime" + + dummy_websocket = _RecordingClientWebSocket() + dummy_logging_obj = MagicMock() + + with patch("websockets.connect", side_effect=OSError("connection reset")): + await handler.async_realtime( # pyright: ignore[reportUnknownMemberType] # same as above + model=model, + websocket=dummy_websocket, + logging_obj=dummy_logging_obj, + api_base="https://example.openai.azure.com", + api_key="bad-key", + api_version="2025-08-28", + query_params={"model": model}, + ) + + assert len(dummy_websocket.sent) == 1 + event = cast(RealtimeErrorEvent, json.loads(dummy_websocket.sent[0])) + assert event["type"] == "error" + assert event["error"]["type"] == "server_error" + assert event["error"]["message"] == "Internal server error" + assert "connection reset" not in dummy_websocket.sent[0] + assert dummy_websocket.closed and dummy_websocket.closed[0] == (1011, "Internal server error") diff --git a/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py b/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py index 512e98b4151..fdd21c87732 100644 --- a/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py +++ b/tests/test_litellm/llms/azure_ai/image_generation/test_azure_ai_flux2_image_generation.py @@ -205,6 +205,36 @@ def test_flux2_flex_cost_accepts_lowercase_model_spelling(): assert cost == pytest.approx(5e-08 * 1536 * 1024 * 2) +def test_flux2_flex_cost_prefers_deployment_input_cost_per_pixel() -> None: + response: Final = ImageResponse(data=[ImageObject(b64_json="aW1n"), ImageObject(b64_json="aW1n")]) + + cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator( + model="FLUX.2-flex", + completion_response=response, + custom_llm_provider="azure_ai", + size="2048x1024", + call_type="image_generation", + model_info={"input_cost_per_pixel": 2e-07}, + ) + + assert cost == pytest.approx(2e-07 * 2048 * 1024 * 2) + + +def test_unlisted_azure_ai_model_bills_deployment_input_cost_per_pixel() -> None: + response: Final = ImageResponse(data=[ImageObject(b64_json="aW1n"), ImageObject(b64_json="aW1n")]) + + cost: Final = CostCalculatorUtils.route_image_generation_cost_calculator( + model="unlisted-flux-deployment", + completion_response=response, + custom_llm_provider="azure_ai", + size="1024x1024", + call_type="image_generation", + model_info={"input_cost_per_pixel": 1e-07}, + ) + + assert cost == pytest.approx(1e-07 * 1024 * 1024 * 2) + + def test_flux2_response_preserves_mapped_dimensions(): config = AzureFoundryFluxImageGenerationConfig() params = config.map_openai_params( diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index db5da28c024..f92c610df24 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5580,43 +5580,6 @@ def test_tool_config_cachepoint_not_placed_or_credited_for_model_without_prompt_ assert "litellm_gateway_injected_cache" not in bucket -def test_translate_response_format_json_schema_still_injects_tool(): - """ - response_format with an explicit json_schema should still use the - synthetic tool call approach (for models that don't support native - structured outputs). - """ - config = AmazonConverseConfig() - - response_format = { - "type": "json_schema", - "json_schema": { - "name": "FactResult", - "schema": { - "type": "object", - "properties": { - "facts": { - "type": "array", - "items": {"type": "string"}, - }, - }, - "required": ["facts"], - }, - }, - } - - optional_params: dict = {} - result = config._translate_response_format_param( - value=response_format, - model="anthropic.claude-3-haiku-20240307-v1:0", - optional_params=optional_params, - non_default_params={"response_format": response_format}, - is_thinking_enabled=False, - ) - - assert result["json_mode"] is True - assert "tools" in result - assert "tool_choice" in result def test_transform_response_finish_reason_stop_when_json_mode_filters_all_tools(): diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 33272a1a9e4..8358d15d30e 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -6,6 +6,8 @@ import pathlib import ssl import threading import weakref +from collections.abc import Callable, Mapping +from typing import Final from unittest.mock import MagicMock, patch import certifi @@ -23,6 +25,7 @@ from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, get_ssl_configuration, ) +from litellm.types.llms.custom_http import VerifyTypes @pytest.mark.asyncio @@ -1396,6 +1399,47 @@ async def test_finalizer_on_live_loop_disposes_foreign_loop_session_without_sche assert session.closed +class _RetryClientHandler(AsyncHTTPHandler): + def __init__(self, first: httpx.AsyncClient, retry: httpx.AsyncClient) -> None: + self._retry_client: Final = retry + super().__init__() + self.client = first + + def create_client( + self, + timeout: float | httpx.Timeout | None = None, + event_hooks: Mapping[str, list[Callable[..., object]]] | None = None, + ssl_verify: VerifyTypes | None = None, + shared_session: ClientSession | None = None, + ) -> httpx.AsyncClient: + return self._retry_client + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["post", "put", "patch", "delete"]) +async def test_connection_error_retry_forwards_content(method: str): + captured: list[bytes] = [] # mutable-ok: async closure capture buffer + + async def raise_connection_error(request: httpx.Request) -> httpx.Response: + raise httpx.RemoteProtocolError("connection dropped", request=request) + + async def capture_and_succeed(request: httpx.Request) -> httpx.Response: + captured.append(request.content) + return httpx.Response(200, request=request) + + first: Final = httpx.AsyncClient(transport=httpx.MockTransport(raise_connection_error)) + retry: Final = httpx.AsyncClient(transport=httpx.MockTransport(capture_and_succeed)) + async with first, retry: + handler: Final = _RetryClientHandler(first=first, retry=retry) + + body = b'{"post": ["run1"]}' + await getattr(handler, method)("https://api.example.com/runs/batch", content=body) + + assert captured == [body], "the retried request must carry the same content= body" + await handler.close() + + + @pytest.fixture def forward_proxy_server(): """Plain HTTP forward proxy that records the absolute URIs it is asked to fetch.""" diff --git a/tests/test_litellm/llms/databricks/test_databricks_cost_calculator.py b/tests/test_litellm/llms/databricks/test_databricks_cost_calculator.py index afac7b0bc1a..de0e547c0cd 100644 --- a/tests/test_litellm/llms/databricks/test_databricks_cost_calculator.py +++ b/tests/test_litellm/llms/databricks/test_databricks_cost_calculator.py @@ -153,14 +153,6 @@ def test_uncached_request_bills_every_prompt_token_at_the_input_rate(local_model assert completion_cost == pytest.approx(200 * info["output_cost_per_token"]) -def test_legacy_endpoint_names_still_resolve(local_model_cost_map: None) -> None: - info: Final = _model_info("databricks/databricks-mixtral-8x7b-instruct") - usage: Final = Usage(prompt_tokens=100, completion_tokens=100, total_tokens=200) - - prompt_cost, completion_cost = cost_per_token(model="databricks/mixtral-8x7b-instruct-v0.1", usage=usage) - - assert prompt_cost == pytest.approx(100 * info["input_cost_per_token"]) - assert completion_cost == pytest.approx(100 * info["output_cost_per_token"]) @pytest.mark.parametrize("model", NEW_MODELS) diff --git a/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py b/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py index 41e8fc0c8c5..b7a79f03f8d 100644 --- a/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fal_ai/chat/test_fal_ai_chat_transformation.py @@ -249,6 +249,23 @@ def test_map_openai_params_drops_unknown_reasoning_effort_when_dropping(): assert "reasoning" not in mapped +@pytest.mark.parametrize("effort", [{"level": "low"}, ["low"], 1]) +def test_map_openai_params_rejects_non_string_reasoning_effort(effort: object) -> None: + with pytest.raises(FalAIError) as exc_info: + FalAIChatConfig().map_openai_params( + non_default_params={"reasoning_effort": effort}, optional_params={}, model=MODEL, drop_params=False + ) + assert exc_info.value.status_code == 400 + + +@pytest.mark.parametrize("effort", [{"level": "low"}, ["low"], 1]) +def test_map_openai_params_drops_non_string_reasoning_effort_when_dropping(effort: object) -> None: + mapped = FalAIChatConfig().map_openai_params( + non_default_params={"reasoning_effort": effort}, optional_params={}, model=MODEL, drop_params=True + ) + assert "reasoning" not in mapped + + def test_map_openai_params_maps_sampling_params(): mapped = FalAIChatConfig().map_openai_params( non_default_params={"temperature": 0.5, "top_p": 0.7, "max_tokens": 10}, diff --git a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py index 56dcba04b5c..3c6e6aea090 100644 --- a/tests/test_litellm/llms/fal_ai/test_cost_calculator.py +++ b/tests/test_litellm/llms/fal_ai/test_cost_calculator.py @@ -7,6 +7,7 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils from litellm.llms.fal_ai.cost_calculator import cost_calculator, fal_ai_passthrough_cost from litellm.types.utils import ImageObject, ImageResponse + @pytest.fixture(autouse=True) def _use_local_model_cost_map(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") @@ -203,3 +204,41 @@ def test_passthrough_trellis_2_without_resolution_falls_back_to_default_rate(): def test_passthrough_unknown_model_returns_none(): assert fal_ai_passthrough_cost("fal-ai/no-such-model", {"resolution": 512}) is None + + +def test_passthrough_string_resolution_is_priced_like_the_integer(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/keyed-model", + { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.3, + "output_cost_per_image_512": 0.25, + "output_cost_per_image_1536": 0.35, + }, + ) + assert fal_ai_passthrough_cost("fal-ai/keyed-model", {"resolution": "512"}) == 0.25 + assert fal_ai_passthrough_cost("fal-ai/keyed-model", {"resolution": 512}) == 0.25 + assert fal_ai_passthrough_cost("fal-ai/keyed-model", {"resolution": "1536"}) == 0.35 + assert fal_ai_passthrough_cost("fal-ai/keyed-model", {"resolution": True}) == 0.3 + assert fal_ai_passthrough_cost("fal-ai/keyed-model", {"resolution": 512.0}) == 0.3 + + +def test_passthrough_cost_is_none_only_when_no_price_applies_to_the_request(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/priceless-model", + {"litellm_provider": "fal_ai", "mode": "image_generation"}, + ) + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/keyed-only-model", + {"litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_image_512": 0.02}, + ) + assert fal_ai_passthrough_cost("fal-ai/priceless-model", {}) is None + assert fal_ai_passthrough_cost("fal-ai/priceless-model", {"resolution": 512}) is None + assert fal_ai_passthrough_cost("fal-ai/no-such-model", {}) is None + assert fal_ai_passthrough_cost("fal-ai/keyed-only-model", {}) is None + assert fal_ai_passthrough_cost("fal-ai/keyed-only-model", {"resolution": 1024}) is None + assert fal_ai_passthrough_cost("fal-ai/keyed-only-model", {"resolution": "512"}) == 0.02 diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py index 86ecbf6701b..d4ad22ca5a0 100644 --- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -1,3 +1,4 @@ +import sys from typing import Final from unittest.mock import AsyncMock, Mock @@ -86,6 +87,17 @@ class TestFalAIVideoTransformation: assert mapped["reference_image_urls"] == [url] assert "image_url" not in mapped + def test_map_openai_params_h3_omits_auto_duration(self): + assert self.config.map_openai_params({"seconds": "auto"}, H3_TEXT_MODEL, False) == {} + assert self.config.map_openai_params({"seconds": "auto"}, MODEL, False) == {"duration": "auto"} + + def test_map_openai_params_h3_size_beyond_tiers_uses_top_resolution(self): + side = str(sys.maxsize + 1) + assert self.config.map_openai_params({"size": f"{side}x{side}"}, H3_TEXT_MODEL, False) == { + "resolution": "4K", + "aspect_ratio": "1:1", + } + def test_transform_video_create_request(self): body, files, url = self.config.transform_video_create_request( model=MODEL, @@ -377,6 +389,117 @@ class TestFalAIVideoTransformation: assert "input.reference_image_urls: Failed to download the file" in video.error["message"] client.get.assert_awaited_once_with(url=result_url, headers=auth_headers) + def test_status_completed_result_probe_reuses_status_client_and_extra_headers(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + status_headers: Final = { + "Authorization": "Key synthetic-fal-key", + "Content-Type": "application/json", + "X-Routing": "canary-7", + } + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=status_headers), + ) + result_url: Final = status_url.removesuffix("/status") + status_client: Final = Mock() + status_client.get.return_value = httpx.Response( + 403, text="missing X-Routing", request=httpx.Request("GET", result_url) + ) + factory_client: Final = Mock() + config = FalAIVideoConfig(sync_client_factory=lambda: factory_client) + + video = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=status_client, + ) + + assert video.status == "failed" + assert video.error == {"code": "fal_error", "message": "missing X-Routing"} + factory_client.get.assert_not_called() + status_client.get.assert_called_once() + assert status_client.get.call_args.kwargs["url"] == result_url + assert status_client.get.call_args.kwargs["headers"].items() >= status_headers.items() + + def test_status_completed_result_probe_transport_error_keeps_completed(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url), + ) + client: Final = Mock() + client.get.side_effect = httpx.ReadError("connection reset by fal.ai") + + video = FalAIVideoConfig().transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=client, + ) + + assert video.status == "completed" + assert video.error is None + + @pytest.mark.asyncio + async def test_async_status_completed_result_probe_reuses_status_client_and_extra_headers(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + status_headers: Final = { + "Authorization": "Key synthetic-fal-key", + "Content-Type": "application/json", + "X-Routing": "canary-7", + } + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=status_headers), + ) + result_url: Final = status_url.removesuffix("/status") + status_client: Final = Mock() + status_client.get = AsyncMock( + return_value=httpx.Response(403, text="missing X-Routing", request=httpx.Request("GET", result_url)) + ) + factory_client: Final = Mock() + factory_client.get = AsyncMock() + config = FalAIVideoConfig(async_client_factory=lambda: factory_client) + + video = await config.async_transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=status_client, + ) + + assert video.status == "failed" + assert video.error == {"code": "fal_error", "message": "missing X-Routing"} + factory_client.get.assert_not_awaited() + status_client.get.assert_awaited_once() + assert status_client.get.await_args.kwargs["url"] == result_url + assert status_client.get.await_args.kwargs["headers"].items() >= status_headers.items() + + @pytest.mark.asyncio + async def test_async_status_completed_result_probe_transport_error_keeps_completed(self): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url), + ) + client: Final = Mock() + client.get = AsyncMock(side_effect=httpx.ConnectError("tls handshake failed")) + + video = await FalAIVideoConfig().async_transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + client=client, + ) + + assert video.status == "completed" + assert video.error is None + def test_status_in_progress_does_not_fetch_result(self): status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" response = httpx.Response( @@ -552,17 +675,23 @@ class TestFalAIVideoTransformation: row = litellm.model_cost[f"fal_ai/{H3_TEXT_MODEL}"] model_info = litellm.get_model_info(model=H3_TEXT_MODEL, custom_llm_provider="fal_ai") - assert video_generation_cost( - model=H3_TEXT_MODEL, - duration_seconds=5, - custom_llm_provider="fal_ai", - model_info=model_info, - video_resolution="2K", - ) == 5 * row["output_cost_per_second_2k"] - assert video_generation_cost( - model=H3_TEXT_MODEL, - duration_seconds=5, - custom_llm_provider="fal_ai", - model_info=model_info, - video_resolution="768p", - ) == 5 * row["output_cost_per_second_768p"] + assert ( + video_generation_cost( + model=H3_TEXT_MODEL, + duration_seconds=5, + custom_llm_provider="fal_ai", + model_info=model_info, + video_resolution="2K", + ) + == 5 * row["output_cost_per_second_2k"] + ) + assert ( + video_generation_cost( + model=H3_TEXT_MODEL, + duration_seconds=5, + custom_llm_provider="fal_ai", + model_info=model_info, + video_resolution="768p", + ) + == 5 * row["output_cost_per_second_768p"] + ) diff --git a/tests/test_litellm/llms/gemini/test_cost_calculator.py b/tests/test_litellm/llms/gemini/test_cost_calculator.py index 5eed11dff03..b2633c6091b 100644 --- a/tests/test_litellm/llms/gemini/test_cost_calculator.py +++ b/tests/test_litellm/llms/gemini/test_cost_calculator.py @@ -200,172 +200,12 @@ def test_maps_no_usage_details(): assert cost_per_google_maps_grounding_request(usage=usage, model_info=model_info) == 0.0 -def test_gemini_image_edit_cost_prefers_token_usage_metadata(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - - input_text_tokens = 20 - input_image_tokens = 1120 - output_image_tokens = 1120 - prompt_tokens = input_text_tokens + input_image_tokens - image_response = ImageResponse( - data=[ImageObject(b64_json="img1"), ImageObject(b64_json="img2")], - usage=ImageUsage( - input_tokens=prompt_tokens, - input_tokens_details=ImageUsageInputTokensDetails( - text_tokens=input_text_tokens, - image_tokens=input_image_tokens, - ), - output_tokens=output_image_tokens, - total_tokens=prompt_tokens + output_image_tokens, - ), - ) - - cost = gemini_image_edit_cost_calculator( - model=model, - image_response=image_response, - ) - - expected_cost = ( - prompt_tokens * model_info["input_cost_per_token"] - + output_image_tokens * model_info["output_cost_per_image_token"] - ) - flat_image_cost = ( - len(image_response.data or []) * model_info["output_cost_per_image"] - ) - assert round(cost, 10) == round(expected_cost, 10) - assert cost != flat_image_cost -def test_gemini_image_edit_cost_uses_output_token_details(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - - input_text_tokens = 20 - output_text_tokens = 213 - output_image_tokens = 1120 - output_tokens = output_text_tokens + output_image_tokens - image_response = ImageResponse( - data=[ImageObject(b64_json="img1")], - usage=ImageUsage( - input_tokens=input_text_tokens, - input_tokens_details=ImageUsageInputTokensDetails( - text_tokens=input_text_tokens, - image_tokens=0, - ), - output_tokens=output_tokens, - total_tokens=input_text_tokens + output_tokens, - prompt_tokens=input_text_tokens, - completion_tokens=output_tokens, - prompt_tokens_details={ - "text_tokens": input_text_tokens, - "image_tokens": 0, - }, - completion_tokens_details={ - "text_tokens": output_text_tokens, - "image_tokens": output_image_tokens, - }, - output_tokens_details={ - "text_tokens": output_text_tokens, - "image_tokens": output_image_tokens, - }, - ), - ) - - cost = gemini_image_edit_cost_calculator( - model=model, - image_response=image_response, - ) - - expected_cost = ( - input_text_tokens * model_info["input_cost_per_token"] - + output_text_tokens * model_info["output_cost_per_token"] - + output_image_tokens * model_info["output_cost_per_image_token"] - ) - all_output_as_image_cost = ( - input_text_tokens * model_info["input_cost_per_token"] - + (output_text_tokens + output_image_tokens) - * model_info["output_cost_per_image_token"] - ) - assert round(cost, 10) == round(expected_cost, 10) - assert cost != all_output_as_image_cost -def test_gemini_image_generation_cost_uses_output_token_details(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - - input_text_tokens = 20 - output_text_tokens = 213 - output_image_tokens = 1120 - output_tokens = output_text_tokens + output_image_tokens - image_response = ImageResponse( - data=[ImageObject(b64_json="img1")], - usage=ImageUsage( - input_tokens=input_text_tokens, - input_tokens_details=ImageUsageInputTokensDetails( - text_tokens=input_text_tokens, - image_tokens=0, - ), - output_tokens=output_tokens, - total_tokens=input_text_tokens + output_tokens, - prompt_tokens=input_text_tokens, - completion_tokens=output_tokens, - prompt_tokens_details={ - "text_tokens": input_text_tokens, - "image_tokens": 0, - }, - completion_tokens_details={ - "text_tokens": output_text_tokens, - "image_tokens": output_image_tokens, - }, - output_tokens_details={ - "text_tokens": output_text_tokens, - "image_tokens": output_image_tokens, - }, - ), - ) - - cost = gemini_image_generation_cost_calculator( - model=model, - image_response=image_response, - ) - - expected_cost = ( - input_text_tokens * model_info["input_cost_per_token"] - + output_text_tokens * model_info["output_cost_per_token"] - + output_image_tokens * model_info["output_cost_per_image_token"] - ) - all_output_as_image_cost = ( - input_text_tokens * model_info["input_cost_per_token"] - + (output_text_tokens + output_image_tokens) - * model_info["output_cost_per_image_token"] - ) - assert round(cost, 10) == round(expected_cost, 10) - assert cost != all_output_as_image_cost -def test_gemini_image_edit_cost_falls_back_to_flat_image_pricing(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - image_response = ImageResponse( - data=[ImageObject(b64_json="img1"), ImageObject(b64_json="img2")] - ) - - cost = gemini_image_edit_cost_calculator( - model=model, - image_response=image_response, - ) - - assert cost == len(image_response.data or []) * model_info["output_cost_per_image"] def _image_response_with_web_search(web_search_requests): @@ -383,43 +223,8 @@ def _image_response_with_web_search(web_search_requests): return ImageResponse(data=[ImageObject(b64_json="img1")], usage=usage) -def test_gemini_image_generation_cost_adds_web_search_grounding(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - model = "gemini/gemini-3-pro-image-preview" - model_info = litellm.get_model_info(model=model, custom_llm_provider="gemini") - - grounded = gemini_image_generation_cost_calculator( - model=model, - image_response=_image_response_with_web_search(2), - ) - ungrounded = gemini_image_generation_cost_calculator( - model=model, - image_response=_image_response_with_web_search(None), - ) - - expected_web_search_cost = cost_per_web_search_request( - usage=_make_usage(2), model_info=model_info - ) - assert expected_web_search_cost > 0 - assert round(grounded - ungrounded, 10) == round(expected_web_search_cost, 10) -def test_gemini_image_generation_cost_no_web_search_when_absent(monkeypatch): - monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - litellm.model_cost = litellm.get_model_cost_map(url="") - model = "gemini/gemini-3-pro-image-preview" - - cost_zero = gemini_image_generation_cost_calculator( - model=model, - image_response=_image_response_with_web_search(0), - ) - cost_none = gemini_image_generation_cost_calculator( - model=model, - image_response=_image_response_with_web_search(None), - ) - - assert cost_zero == cost_none @pytest.mark.parametrize( diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py index f1f1978b06f..b1d7e49fbac 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -371,62 +371,8 @@ def test_x_initiator_header_system_only_messages(): assert headers["X-Initiator"] == "user" -def test_get_supported_openai_params_claude_model(): - """Test that Claude models with extended thinking support have thinking and reasoning parameters.""" - config = GithubCopilotConfig() - - # Test Claude 4 model supports thinking and reasoning_effort parameters - supported_params = config.get_supported_openai_params("claude-sonnet-4-20250514") - assert "thinking" in supported_params - assert "reasoning_effort" in supported_params - - # Test Claude 3-7 model supports thinking and reasoning_effort parameters - supported_params_claude37 = config.get_supported_openai_params( - "claude-3-7-sonnet-20250219" - ) - assert "thinking" in supported_params_claude37 - assert "reasoning_effort" in supported_params_claude37 - - # Test Claude 3.5 model does NOT support thinking parameters (no extended thinking) - supported_params_claude35 = config.get_supported_openai_params("claude-3.5-sonnet") - assert "thinking" not in supported_params_claude35 - assert "reasoning_effort" not in supported_params_claude35 - - # Test non-Claude model doesn't include thinking parameters but may include reasoning_effort - supported_params_gpt = config.get_supported_openai_params("gpt-4o") - assert "thinking" not in supported_params_gpt - # gpt-4o should NOT have reasoning_effort (not a reasoning model) - assert "reasoning_effort" not in supported_params_gpt - - # Test O-series reasoning models include reasoning_effort but not thinking - supported_params_o3 = config.get_supported_openai_params("o3-mini") - assert "thinking" not in supported_params_o3 - # o3-mini should have reasoning_effort (it's an O-series reasoning model) - assert "reasoning_effort" in supported_params_o3 -def test_get_supported_openai_params_case_insensitive(): - """Test that Claude model detection is case-insensitive for models with extended thinking.""" - config = GithubCopilotConfig() - - # Test uppercase Claude 4 model with full model name - supported_params_upper = config.get_supported_openai_params( - "CLAUDE-SONNET-4-20250514" - ) - assert "thinking" in supported_params_upper - assert "reasoning_effort" in supported_params_upper - - # Test mixed case Claude 3-7 model (has extended thinking) with full model name - supported_params_mixed = config.get_supported_openai_params( - "Claude-3-7-Sonnet-20250219" - ) - assert "thinking" in supported_params_mixed - assert "reasoning_effort" in supported_params_mixed - - # Test that Claude 3.5 models don't have thinking support (case insensitive) - supported_params_35 = config.get_supported_openai_params("CLAUDE-3.5-SONNET") - assert "thinking" not in supported_params_35 - assert "reasoning_effort" not in supported_params_35 def test_copilot_vision_request_header_with_image(): diff --git a/tests/test_litellm/llms/openai/realtime/test_openai_realtime_handler.py b/tests/test_litellm/llms/openai/realtime/test_openai_realtime_handler.py index 4221954d787..7cd2b9e259c 100644 --- a/tests/test_litellm/llms/openai/realtime/test_openai_realtime_handler.py +++ b/tests/test_litellm/llms/openai/realtime/test_openai_realtime_handler.py @@ -416,3 +416,52 @@ async def test_async_realtime_ws_url_has_no_ssl(): # Verify ssl is None for ws:// URLs (the fix for issue #19222) assert called_kwargs["ssl"] is None + + +@pytest.mark.asyncio +async def test_async_realtime_upstream_handshake_refusal_sends_error_event_then_policy_close(): + from typing import cast + + import websockets + + from litellm.llms.openai.realtime.handler import OpenAIRealtime + from litellm.types.realtime import RealtimeErrorEvent + + handler = OpenAIRealtime() + model = "gpt-realtime" + + sent: list[str] = [] + closed: list[tuple[int, str | None]] = [] + + class RecordingClientWebSocket: + scope: dict[str, list[tuple[bytes, bytes]]] = {"headers": []} + + async def send_text(self, data: str) -> None: + sent.append(data) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + closed.append((code, reason)) + + dummy_websocket = RecordingClientWebSocket() + dummy_logging_obj = MagicMock() + + refused = websockets.exceptions.InvalidStatus( + websockets.http11.Response(401, "Unauthorized", websockets.datastructures.Headers()) + ) + + with patch("websockets.connect", side_effect=refused): + await handler.async_realtime( # pyright: ignore[reportUnknownMemberType] # handler's websocket param is Any + model=model, + websocket=dummy_websocket, + logging_obj=dummy_logging_obj, + api_base="https://api.openai.com/", + api_key="bad-key", + query_params={"model": model}, + ) + + assert len(sent) == 1 + event = cast(RealtimeErrorEvent, json.loads(sent[0])) + assert event["type"] == "error" + assert event["error"]["type"] == "server_error" + assert "401" in event["error"]["message"] + assert closed and closed[0][0] == 1008 diff --git a/tests/test_litellm/llms/openai/test_gpt5_transformation.py b/tests/test_litellm/llms/openai/test_gpt5_transformation.py index 0adc7fa8d5f..41f5816600f 100644 --- a/tests/test_litellm/llms/openai/test_gpt5_transformation.py +++ b/tests/test_litellm/llms/openai/test_gpt5_transformation.py @@ -38,10 +38,6 @@ def test_gpt5_supports_reasoning_effort(config: OpenAIConfig): assert "reasoning_effort" in config.get_supported_openai_params(model="gpt-5-mini") -def test_gpt5_chat_does_not_support_reasoning_effort(config: OpenAIConfig): - assert "reasoning_effort" not in config.get_supported_openai_params( - model="gpt-5-chat-latest" - ) def test_gpt5_chat_supports_temperature(config: OpenAIConfig): @@ -174,10 +170,6 @@ def test_gpt5_codex_unsupported_params_drop(config: OpenAIConfig): assert param not in config.get_supported_openai_params(model="gpt-5-codex") -def test_gpt5_codex_supports_tool_choice(gpt5_config: OpenAIGPT5Config): - """Test that GPT-5-Codex supports tool_choice parameter.""" - supported_params = gpt5_config.get_supported_openai_params(model="gpt-5-codex") - assert "tool_choice" in supported_params def test_gpt5_codex_supports_function_calling(config: OpenAIConfig): @@ -246,14 +238,6 @@ def test_gpt5_1_reasoning_effort_none(config: OpenAIConfig): assert params["reasoning_effort"] == effort -def test_gpt5_1_codex_max_allows_reasoning_effort_xhigh(config: OpenAIConfig): - params = config.map_openai_params( - non_default_params={"reasoning_effort": "xhigh"}, - optional_params={}, - model="gpt-5.1-codex-max", - drop_params=False, - ) - assert params["reasoning_effort"] == "xhigh" def test_gpt5_rejects_reasoning_effort_xhigh_for_other_models(config: OpenAIConfig): diff --git a/tests/test_litellm/llms/openai_like/test_provider_affinity_forwarding.py b/tests/test_litellm/llms/openai_like/test_provider_affinity_forwarding.py new file mode 100644 index 00000000000..43101234e63 --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_provider_affinity_forwarding.py @@ -0,0 +1,389 @@ +import json +from unittest.mock import MagicMock + +import httpx +import pytest + +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.llms.openai_like import dynamic_config +from litellm.llms.openai_like.json_loader import JSONProviderRegistry, SimpleProviderConfig + + +def _provider(*, responses: bool = False) -> SimpleProviderConfig: + endpoints = ["/v1/chat/completions"] + if responses: + endpoints.append("/v1/responses") + return SimpleProviderConfig( + "db_only_provider", + { + "base_url": "https://db-only.example/v1", + "api_key_env": "DYNAMIC_PROVIDER_API_KEY", + "supported_endpoints": endpoints, + }, + ) + + +def _chat_response_payload(content: str = "dynamic response") -> dict[str, object]: + return { + "id": "chatcmpl-dynamic-provider", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}, + } + + +def _responses_payload() -> dict[str, object]: + return { + "id": "resp_dynamic_provider", + "object": "response", + "created_at": 1234567890, + "status": "completed", + "model": "test-model", + "output": [], + "parallel_tool_calls": True, + "usage": {"input_tokens": 2, "output_tokens": 2, "total_tokens": 4}, + "error": None, + } + + +@pytest.fixture(autouse=True) +def _isolate_registry_state(): + original_providers = dict(JSONProviderRegistry._providers) + dynamic_config._responses_config_cache.clear() + yield + JSONProviderRegistry._providers = original_providers + dynamic_config._responses_config_cache.clear() + + +def test_dynamic_provider_receives_affinity_header_for_chat(): + from openai import OpenAI + + JSONProviderRegistry._providers = {"db_only_provider": _provider()} + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_chat_response_payload()) + + client = OpenAI( + api_key="test-key", + base_url="https://db-only.example/v1", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + try: + litellm.completion( + model="db_only_provider/test-model", + messages=[{"role": "user", "content": "hello"}], + api_key="test-key", + client=client, + extra_headers={"X-Customer-Header": "customer-value"}, + litellm_session_id="session-sync", + provider_affinity_header="X-Conversation-Id", + ) + finally: + client.close() + + assert requests[0].headers["x-conversation-id"] == "session-sync" + assert requests[0].headers["x-customer-header"] == "customer-value" + + +def test_dynamic_provider_does_not_use_trace_id_for_chat_affinity(): + from openai import OpenAI + + JSONProviderRegistry._providers = {"db_only_provider": _provider()} + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_chat_response_payload()) + + client = OpenAI( + api_key="test-key", + base_url="https://db-only.example/v1", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + try: + litellm.completion( + model="db_only_provider/test-model", + messages=[{"role": "user", "content": "hello"}], + api_key="test-key", + client=client, + metadata={"trace_id": "per-request-trace"}, + provider_affinity_header="X-Conversation-Id", + ) + finally: + client.close() + + assert "x-conversation-id" not in requests[0].headers + + +@pytest.mark.asyncio +async def test_dynamic_provider_receives_affinity_header_for_async_chat(): + from openai import AsyncOpenAI + + JSONProviderRegistry._providers = {"db_only_provider": _provider()} + requests: list[httpx.Request] = [] + + async def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_chat_response_payload("async response")) + + client = AsyncOpenAI( + api_key="test-key", + base_url="https://db-only.example/v1", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)), + ) + try: + response = await litellm.acompletion( + model="db_only_provider/test-model", + messages=[{"role": "user", "content": "hello"}], + api_key="test-key", + client=client, + litellm_session_id="session-async", + provider_affinity_header="X-Conversation-Id", + ) + finally: + await client.close() + + assert response.choices[0].message.content == "async response" + assert requests[0].headers["x-conversation-id"] == "session-async" + + +def test_dynamic_provider_receives_affinity_header_for_streaming_chat(): + from openai import OpenAI + + JSONProviderRegistry._providers = {"db_only_provider": _provider()} + chunks = [ + { + "id": "chatcmpl-dynamic-stream", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "streamed"}, + "finish_reason": None, + } + ], + }, + { + "id": "chatcmpl-dynamic-stream", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "test-model", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + }, + ] + stream_body = "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n" + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, content=stream_body, headers={"content-type": "text/event-stream"}) + + client = OpenAI( + api_key="test-key", + base_url="https://db-only.example/v1", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + try: + response_chunks = list( + litellm.completion( + model="db_only_provider/test-model", + messages=[{"role": "user", "content": "hello"}], + api_key="test-key", + client=client, + stream=True, + litellm_session_id="session-stream", + provider_affinity_header="X-Conversation-Id", + ) + ) + finally: + client.close() + + assert any(chunk.choices[0].delta.content == "streamed" for chunk in response_chunks) + assert requests[0].headers["x-conversation-id"] == "session-stream" + + +def test_dynamic_provider_receives_affinity_header_for_responses(): + JSONProviderRegistry._providers = {"db_only_provider": _provider(responses=True)} + logging_obj = MagicMock() + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_responses_payload()) + + http_client = httpx.Client(transport=httpx.MockTransport(respond)) + try: + litellm.responses( + model="db_only_provider/test-model", + input="hello", + api_key="test-key", + litellm_session_id="session-responses", + provider_affinity_header="X-Conversation-Id", + litellm_logging_obj=logging_obj, + client=HTTPHandler(client=http_client), + ) + finally: + http_client.close() + + assert requests[0].headers["X-Conversation-Id"] == "session-responses" + assert ( + logging_obj.update_from_kwargs.call_args.kwargs["litellm_params"]["provider_affinity_header"] + == "X-Conversation-Id" + ) + + +def test_dynamic_provider_uses_metadata_session_id_for_responses(): + JSONProviderRegistry._providers = {"db_only_provider": _provider(responses=True)} + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_responses_payload()) + + http_client = httpx.Client(transport=httpx.MockTransport(respond)) + try: + litellm.responses( + model="db_only_provider/test-model", + input="hello", + api_key="test-key", + metadata={"session_id": "session-from-metadata"}, + provider_affinity_header="X-Conversation-Id", + litellm_logging_obj=MagicMock(), + client=HTTPHandler(client=http_client), + ) + finally: + http_client.close() + + assert requests[0].headers["X-Conversation-Id"] == "session-from-metadata" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) +async def test_dynamic_provider_receives_affinity_header_for_async_responses(stream: bool): + JSONProviderRegistry._providers = {"db_only_provider": _provider(responses=True)} + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_responses_payload()) + + client = AsyncHTTPHandler() + await client.close() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + try: + response = await litellm.aresponses( + model="db_only_provider/test-model", + input="hello", + api_key="test-key", + stream=stream, + litellm_session_id="session-async-responses", + provider_affinity_header="X-Conversation-Id", + client=client, + ) + finally: + await client.client.aclose() + + assert requests[0].headers["X-Conversation-Id"] == "session-async-responses" + if stream: + assert hasattr(response, "__aiter__") + else: + assert getattr(response, "model", None) == "test-model" + + +def test_control_characters_in_session_id_are_a_bad_request_for_chat(): + from openai import OpenAI + + JSONProviderRegistry._providers = {"db_only_provider": _provider()} + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_chat_response_payload()) + + client = OpenAI( + api_key="test-key", + base_url="https://db-only.example/v1", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + try: + with pytest.raises(litellm.BadRequestError, match="HTTP header control characters"): + litellm.completion( + model="db_only_provider/test-model", + messages=[{"role": "user", "content": "hello"}], + api_key="test-key", + client=client, + litellm_session_id="session\nsplit", + provider_affinity_header="X-Conversation-Id", + ) + finally: + client.close() + + assert requests == [] + + +def test_control_characters_in_session_id_are_a_bad_request_for_responses(): + JSONProviderRegistry._providers = {"db_only_provider": _provider(responses=True)} + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_responses_payload()) + + http_client = httpx.Client(transport=httpx.MockTransport(respond)) + try: + with pytest.raises(litellm.BadRequestError, match="HTTP header control characters"): + litellm.responses( + model="db_only_provider/test-model", + input="hello", + api_key="test-key", + litellm_session_id="session\nsplit", + provider_affinity_header="X-Conversation-Id", + litellm_logging_obj=MagicMock(), + client=HTTPHandler(client=http_client), + ) + finally: + http_client.close() + + assert requests == [] + + +def test_builtin_provider_receives_affinity_header(): + from openai import OpenAI + + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_chat_response_payload()) + + client = OpenAI( + api_key="test-key", + base_url="https://api.openai.com/v1", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + try: + litellm.completion( + model="openai/test-model", + messages=[{"role": "user", "content": "hello"}], + api_key="test-key", + client=client, + litellm_session_id="session-builtin", + provider_affinity_header="X-Conversation-Id", + ) + finally: + client.close() + + assert requests[0].headers["X-Conversation-Id"] == "session-builtin" diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index a796e2ac607..8b0662de3f9 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -5948,8 +5948,8 @@ def test_calculate_web_search_requests_counts_unique_queries(): @pytest.mark.parametrize("custom_llm_provider", ["gemini", "vertex_ai"]) @pytest.mark.parametrize( "model", - ["gemini-2.5-flash", "gemini-3-pro-preview"], - ids=["thinking_budget_mapper", "thinking_level_mapper"], + ["gemini-2.5-flash"], + ids=["thinking_budget_mapper"], ) @pytest.mark.parametrize("reasoning_effort", ["banana", "xhigh"]) def test_invalid_reasoning_effort_is_a_400_not_a_500(custom_llm_provider, model, reasoning_effort): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py index 58e7529309a..4199e57d2f9 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -238,31 +238,3 @@ def test_audio_predict_response_supports_bytes_base64_encoded( assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) -def test_image_predict_response_is_not_billed_as_audio( - local_model_cost_map: None, -) -> None: - logging_obj = MagicMock() - logging_obj.model_call_details = {} - response = httpx.Response( - status_code=200, - json={"predictions": [{"bytesBase64Encoded": "frame", "mimeType": "image/png"}]}, - ) - - result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( - httpx_response=response, - logging_obj=logging_obj, - url_route=( - "/v1/projects/test/locations/us-central1/publishers/google/models/imagen-4.0-generate-001:predict" - ), - result=response.text, - start_time=datetime.now(), - end_time=datetime.now(), - cache_hit=False, - request_body={"instances": [{"prompt": "a red cube"}]}, - ) - - assert isinstance(result["result"], litellm.ImageResponse) - assert logging_obj.call_type == PassthroughCallTypes.passthrough_image_generation.value - assert result["kwargs"]["response_cost"] == pytest.approx( - litellm.model_cost["vertex_ai/imagen-4.0-generate-001"]["output_cost_per_image"] - ) diff --git a/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py b/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py index a25ed585ecc..5c168d84766 100644 --- a/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py +++ b/tests/test_litellm/llms/wandb/test_wandb_chat_transformation.py @@ -37,10 +37,6 @@ WANDB_REASONING_MODELS: Final = ( "Qwen/Qwen3.5-35B-A3B", "zai-org/GLM-5.2", "moonshotai/Kimi-K2.5", - "MiniMaxAI/MiniMax-M2.5", - "zai-org/GLM-4.5", - "Qwen/Qwen3-235B-A22B-Thinking-2507", - "deepseek-ai/DeepSeek-R1-0528", ) diff --git a/tests/test_litellm/llms/xai/test_xai_cost_calculator.py b/tests/test_litellm/llms/xai/test_xai_cost_calculator.py index cf3bc73a225..6065f1053e7 100644 --- a/tests/test_litellm/llms/xai/test_xai_cost_calculator.py +++ b/tests/test_litellm/llms/xai/test_xai_cost_calculator.py @@ -152,83 +152,10 @@ class TestXAICostCalculator: setattr(reported, "server_side_tool_usage_details", {"web_search_calls": 3}) assert get_cost_for_web_search_request("xai", reported, {}) == 0.0 - def test_no_reported_cost_falls_back_to_token_math(self): - """Absent the provider figure, nothing changes for existing callers.""" - usage = Usage(prompt_tokens=100, completion_tokens=200, total_tokens=300) - prompt_cost, completion_cost = cost_per_token(model="grok-4-latest", usage=usage) - assert prompt_cost > 0.0 - assert completion_cost > 0.0 - def test_malformed_reported_cost_falls_back_to_token_math(self): - """A junk value must not fail the request, fall back to calculating.""" - usage = Usage(prompt_tokens=100, completion_tokens=200, total_tokens=300) - setattr(usage, "cost", "not-a-number") - prompt_cost, completion_cost = cost_per_token(model="grok-4-latest", usage=usage) - - assert prompt_cost > 0.0 - assert completion_cost > 0.0 - - def test_boolean_reported_cost_falls_back_to_token_math(self): - """True is an int in python and would otherwise be billed as $1.""" - usage = Usage(prompt_tokens=100, completion_tokens=200, total_tokens=300) - setattr(usage, "cost", True) - - prompt_cost, completion_cost = cost_per_token(model="grok-4-latest", usage=usage) - - assert prompt_cost > 0.0 - assert completion_cost > 0.0 - assert completion_cost != 1.0 - - def test_negative_reported_cost_is_rejected(self): - """A negative amount must never reach spend tracking. - - A caller who can set api_base controls the response body, so trusting a - negative figure would let them subtract from their own recorded spend and - slip past a budget. Fall back to token pricing instead, and keep charging - the web search surcharge, since no trustworthy total was reported. - """ - usage = Usage( - prompt_tokens=100, - completion_tokens=200, - total_tokens=300, - cost=-0.0037756, - ) - setattr(usage, "server_side_tool_usage_details", {"web_search_calls": 3}) - - prompt_cost, completion_cost = cost_per_token(model="grok-4-latest", usage=usage) - - assert prompt_cost > 0.0 - assert completion_cost > 0.0 - assert cost_per_web_search_request(usage=usage, model_info={}) > 0.0 - - def test_non_finite_reported_cost_is_rejected(self): - """NaN compares false against every budget threshold. - - Usage stores a provider supplied cost without validating it, so a caller who - controls the response body could report NaN and leave spend >= max_budget - false for the life of the key rather than mispricing one request. The - infinities are refused alongside it. Fall back to token pricing and keep - charging the web search surcharge, since no trustworthy total was reported. - """ - for reported_cost in (float("nan"), float("inf"), float("-inf")): - usage = Usage( - prompt_tokens=100, - completion_tokens=200, - total_tokens=300, - cost=reported_cost, - ) - setattr(usage, "server_side_tool_usage_details", {"web_search_calls": 3}) - - prompt_cost, completion_cost = cost_per_token(model="grok-4-latest", usage=usage) - - assert math.isfinite(prompt_cost), reported_cost - assert math.isfinite(completion_cost), reported_cost - assert prompt_cost > 0.0, reported_cost - assert completion_cost > 0.0, reported_cost - assert cost_per_web_search_request(usage=usage, model_info={}) > 0.0, reported_cost def test_zero_reported_cost_is_honoured(self): """A reported zero is a real answer, not a missing value.""" diff --git a/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py b/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py deleted file mode 100644 index 83e8925f70b..00000000000 --- a/tests/test_litellm/llms/xai/test_xai_redirected_slug_pricing.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -xAI retired eight slugs on 2026-05-15 but kept them resolvable: chat slugs redirect to -grok-4.3 and bill at grok-4.3's rates, while the grok-code-fast slugs are aliases of -grok-build-0.1 and bill at its rates, so the registry must price them that way or spend -tracking is wrong. The grok-3-beta, grok-3-fast, grok-3-mini, and grok-4-1-fast slugs -are absent from /v1/language-models and resolve to grok-4.3 the same way (the chat -response names grok-4.3 as the served model), so they carry grok-4.3's rates too. -https://docs.x.ai/developers/migration/may-15-retirement -https://docs.x.ai/developers/models/grok-build-0.1 -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).parents[4] -PRICES_PATH = REPO_ROOT / "model_prices_and_context_window.json" -BACKUP_PRICES_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json" -MAP_PATHS = (PRICES_PATH, BACKUP_PRICES_PATH) - -REDIRECT_TARGET = "xai/grok-4.3" -GROK_3_MINI_SLUGS = ( - "xai/grok-3-mini", - "xai/grok-3-mini-beta", - "xai/grok-3-mini-fast", - "xai/grok-3-mini-fast-beta", - "xai/grok-3-mini-fast-latest", - "xai/grok-3-mini-latest", -) -REDIRECTED_SLUGS = ( - "xai/grok-3", - "xai/grok-3-beta", - "xai/grok-3-fast-beta", - "xai/grok-3-fast-latest", - "xai/grok-3-latest", - *GROK_3_MINI_SLUGS, - "xai/grok-4", - "xai/grok-4-0709", - "xai/grok-4-1-fast", - "xai/grok-4-1-fast-non-reasoning", - "xai/grok-4-1-fast-non-reasoning-latest", - "xai/grok-4-1-fast-reasoning", - "xai/grok-4-1-fast-reasoning-latest", - "xai/grok-4-fast-non-reasoning", - "xai/grok-4-fast-reasoning", - "xai/grok-4-latest", -) -CODE_REDIRECT_TARGET = "xai/grok-build-0.1" -CODE_SLUGS = ( - "xai/grok-code-fast", - "xai/grok-code-fast-1", - "xai/grok-code-fast-1-0825", -) -BASE_COST_FIELDS = ("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost") -TIER_COST_FIELDS = ( - "input_cost_per_token_above_200k_tokens", - "output_cost_per_token_above_200k_tokens", - "cache_read_input_token_cost_above_200k_tokens", -) - - -@pytest.fixture(scope="module", params=[p.name for p in MAP_PATHS]) -def cost_map(request: pytest.FixtureRequest) -> dict: - path = next(p for p in MAP_PATHS if p.name == request.param) - return json.loads(path.read_text(encoding="utf-8")) - - -@pytest.mark.parametrize("slug", REDIRECTED_SLUGS) -def test_redirected_slug_bills_at_the_target_rate(cost_map: dict, slug: str): - target = cost_map[REDIRECT_TARGET] - entry = cost_map[slug] - for field in BASE_COST_FIELDS: - assert entry[field] == target[field], field - - -@pytest.mark.parametrize("slug", CODE_SLUGS) -def test_code_slug_bills_at_grok_build_rate(cost_map: dict, slug: str): - """grok-code-fast* are aliases of grok-build-0.1, not grok-4.3 redirects.""" - target = cost_map[CODE_REDIRECT_TARGET] - entry = cost_map[slug] - for field in (*BASE_COST_FIELDS, *TIER_COST_FIELDS): - assert entry[field] == target[field], field - - -@pytest.mark.parametrize("slug", REDIRECTED_SLUGS) -def test_redirected_slug_carries_the_target_tier_rates(cost_map: dict, slug: str): - """The request executes as grok-4.3, so it is tiered at grok-4.3's 200k boundary.""" - target = cost_map[REDIRECT_TARGET] - entry = cost_map[slug] - for field in TIER_COST_FIELDS: - assert entry[field] == target[field], field - assert {k for k in entry if "_above_" in k} == {k for k in target if "_above_" in k} - - -def test_both_cost_maps_agree_on_the_redirected_slugs(): - prices = json.loads(PRICES_PATH.read_text(encoding="utf-8")) - backup = json.loads(BACKUP_PRICES_PATH.read_text(encoding="utf-8")) - for slug in (*REDIRECTED_SLUGS, *CODE_SLUGS, REDIRECT_TARGET, CODE_REDIRECT_TARGET): - assert prices[slug] == backup[slug], slug diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 8449fb94831..c58bb4fb48d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -3057,6 +3057,11 @@ class TestMCPDelegateAuthToUpstream: ) cases = [ + ("/mcp/sse", []), + ("/mcp/sse/", []), + ("/mcp/sse/messages", []), + ("/mcp/sse/messages/", []), + ("/sse/mcp", ["sse"]), # Single server, single segment. ("/mcp/foo", ["foo"]), # Server name with one embedded slash (two segments). diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index d80c5e84ee8..ba9a509ccf5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -1147,109 +1147,39 @@ async def test_mcp_read_resource_success(): assert result is read_result -def test_normalize_resource_contents_passes_metadata(): - """Test that _normalize_resource_contents preserves meta from ResourceContents (MCP 1.26.0+).""" - try: - from litellm.proxy._experimental.mcp_server.server import ( - _normalize_resource_contents, - ) - except ImportError: - pytest.skip("MCP server not available") +@pytest.mark.asyncio +@pytest.mark.parametrize( + "kind,metadata", + (("text", {"version": "1.0", "source": "test"}), ("blob", {"encoding": "base64"}), ("text", {}), ("text", None)), +) +async def test_read_resource_preserves_content_metadata(_mcp_request_ctx, kind, metadata): + from mcp.types import ReadResourceRequestParams + from litellm.proxy._experimental.mcp_server import operations, server - meta = {"version": "1.0", "source": "test"} - contents = [ - TextResourceContents( - uri="https://example.com/resource", - text="hello world", - mimeType="text/plain", - meta=meta, - ) - ] + uri: Final = "https://example.com/resource" + caller: Final = UserAPIKeyAuth(user_id="resource-caller") + upstream_server: Final = MCPServer(server_id="catalog", name="catalog", transport=MCPTransport.http) + content: Final = ( + TextResourceContents(uri=uri, text="hello world", mimeType="text/plain", meta=metadata) + if kind == "text" + else BlobResourceContents(uri=uri, blob="aGVsbG8=", mimeType="image/png", meta=metadata) + ) + with ( + patch.object(server, "get_or_extract_auth_context", AsyncMock(return_value=(caller, None, ["catalog"], None, None, None, None))), + patch.object(operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[upstream_server])), + patch.object(operations.global_mcp_server_manager, "read_resource_from_server", AsyncMock(return_value=ReadResourceResult(contents=[content]))), + ): + result: Final = await server.read_resource(_mcp_request_ctx(), ReadResourceRequestParams(uri=uri)) - result = _normalize_resource_contents(contents) - - assert len(result) == 1 - assert result[0].content == "hello world" - assert result[0].mime_type == "text/plain" - assert result[0].meta == meta - - -def test_normalize_resource_contents_blob_with_metadata(): - """Test that _normalize_resource_contents preserves meta for BlobResourceContents.""" - try: - from litellm.proxy._experimental.mcp_server.server import ( - _normalize_resource_contents, - ) - except ImportError: - pytest.skip("MCP server not available") - - meta = {"encoding": "base64"} - contents = [ - BlobResourceContents( - uri="https://example.com/image.png", - blob="aGVsbG8=", - mimeType="image/png", - meta=meta, - ) - ] - - result = _normalize_resource_contents(contents) - - assert len(result) == 1 - assert result[0].content == "aGVsbG8=" - assert result[0].mime_type == "image/png" - assert result[0].meta == meta - - -def test_normalize_resource_contents_preserves_empty_metadata(): - """Test that empty dict meta is preserved (truthiness bug fix).""" - try: - from litellm.proxy._experimental.mcp_server.server import ( - _normalize_resource_contents, - ) - except ImportError: - pytest.skip("MCP server not available") - - empty_meta: dict = {} - contents = [ - TextResourceContents( - uri="https://example.com/resource", - text="hi", - mimeType="text/plain", - meta=empty_meta, - ) - ] - - result = _normalize_resource_contents(contents) - - assert len(result) == 1 - assert result[0].meta == empty_meta - assert result[0].meta is not None - assert result[0].meta == {} - - -def test_normalize_resource_contents_without_metadata(): - """Test that _normalize_resource_contents works when meta is absent (backward compat).""" - try: - from litellm.proxy._experimental.mcp_server.server import ( - _normalize_resource_contents, - ) - except ImportError: - pytest.skip("MCP server not available") - - contents = [ - TextResourceContents( - uri="https://example.com/resource", - text="hello", - mimeType="text/plain", - ) - ] - - result = _normalize_resource_contents(contents) - - assert len(result) == 1 - assert result[0].content == "hello" - assert result[0].meta is None + assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == { + "cacheScope": "private", "resultType": "complete", "ttlMs": 0, + "contents": [{ + "uri": uri, + "mimeType": "text/plain" if kind == "text" else "image/png", + "text" if kind == "text" else "blob": "hello world" if kind == "text" else "aGVsbG8=", + **({"_meta": metadata} if metadata is not None else {}), + }], + } @pytest.mark.asyncio @@ -1859,14 +1789,12 @@ async def test_concurrent_initialize_session_managers(): original_initialized = mcp_server._SESSION_MANAGERS_INITIALIZED original_session_cm = mcp_server._session_manager_cm original_stateful_cm = mcp_server._session_manager_stateful_cm - original_sse_cm = mcp_server._sse_session_manager_cm original_cleanup_task = mcp_server._stateful_auth_context_cleanup_task try: mcp_server._SESSION_MANAGERS_INITIALIZED = False mcp_server._session_manager_cm = None mcp_server._session_manager_stateful_cm = None - mcp_server._sse_session_manager_cm = None # Create mock context managers for all three session managers mock_cm_stateless = AsyncMock() @@ -1877,10 +1805,6 @@ async def test_concurrent_initialize_session_managers(): mock_cm_stateful.__aenter__ = AsyncMock() mock_cm_stateful.__aexit__ = AsyncMock() - mock_cm_sse = AsyncMock() - mock_cm_sse.__aenter__ = AsyncMock() - mock_cm_sse.__aexit__ = AsyncMock() - with ( patch.object( mcp_server.session_manager_stateless, @@ -1892,11 +1816,6 @@ async def test_concurrent_initialize_session_managers(): "run", return_value=mock_cm_stateful, ) as mock_stateful_run, - patch.object( - mcp_server.sse_session_manager, - "run", - return_value=mock_cm_sse, - ) as mock_sse_run, patch("litellm.proxy._experimental.mcp_server.operations.verbose_logger"), ): # Create multiple concurrent tasks that call initialize_session_managers @@ -1918,10 +1837,6 @@ async def test_concurrent_initialize_session_managers(): assert mock_stateful_run.call_count == 1, ( f"Expected 1 call to session_manager_stateful.run(), got {mock_stateful_run.call_count}" ) - assert mock_sse_run.call_count == 1, ( - f"Expected 1 call to sse_session_manager.run(), got {mock_sse_run.call_count}" - ) - # The context managers should only be entered once each assert mock_cm_stateless.__aenter__.call_count == 1, ( f"Expected 1 call to stateless __aenter__, got {mock_cm_stateless.__aenter__.call_count}" @@ -1929,10 +1844,6 @@ async def test_concurrent_initialize_session_managers(): assert mock_cm_stateful.__aenter__.call_count == 1, ( f"Expected 1 call to stateful __aenter__, got {mock_cm_stateful.__aenter__.call_count}" ) - assert mock_cm_sse.__aenter__.call_count == 1, ( - f"Expected 1 call to sse __aenter__, got {mock_cm_sse.__aenter__.call_count}" - ) - # State should be properly set assert mcp_server._SESSION_MANAGERS_INITIALIZED is True @@ -1948,7 +1859,6 @@ async def test_concurrent_initialize_session_managers(): mcp_server._SESSION_MANAGERS_INITIALIZED = original_initialized mcp_server._session_manager_cm = original_session_cm mcp_server._session_manager_stateful_cm = original_stateful_cm - mcp_server._sse_session_manager_cm = original_sse_cm mcp_server._stateful_auth_context_cleanup_task = original_cleanup_task @@ -2263,7 +2173,7 @@ async def test_sse_endpoint_applies_the_same_client_allowlist( new_callable=AsyncMock, ), patch.object( # test-quality-ok: SSE manager is a module singleton; the downstream call is the observable - mcp_module.sse_session_manager, "handle_request", side_effect=handle_request + mcp_module.sse, "handle_post_message", side_effect=handle_request ), ): if admitted: @@ -6651,17 +6561,22 @@ class TestGatewayCreateInitializationOptions: ) captured = {} - async def record_request(scope, receive, send): + @contextlib.asynccontextmanager + async def connect_sse(scope, receive, send): + yield (None, None) + + async def record_request(read_stream, write_stream, options): captured["server_name"] = server.create_initialization_options().server_name scope = { "type": "http", - "method": "POST", + "method": "GET", "path": "/mcp/grafana", "headers": [], } with ( + patch.object(mcp_server.sse, "connect_sse", connect_sse), patch( "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", new_callable=AsyncMock, @@ -6697,8 +6612,8 @@ class TestGatewayCreateInitializationOptions: True, ), patch.object( - mcp_server.sse_session_manager, - "handle_request", + mcp_server.server, + "run", side_effect=record_request, ), ): @@ -8840,7 +8755,7 @@ def _call_tool_result(is_error: bool, text: str) -> CallToolResult: def _mock_mcp_logging_obj() -> MagicMock: logging_obj = MagicMock() logging_obj.model_call_details = {} - logging_obj.async_post_mcp_tool_call_hook = AsyncMock() + logging_obj.async_post_mcp_tool_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["response_obj"]) logging_obj.async_success_handler = AsyncMock() logging_obj.async_failure_handler = AsyncMock() return logging_obj @@ -8942,6 +8857,64 @@ async def test_fire_mcp_tool_call_logging_success_path_unchanged(): proxy_logging_mock.post_call_failure_hook.assert_not_awaited() +@pytest.mark.asyncio +async def test_fire_mcp_tool_call_logging_applies_hook_content(): + from litellm.integrations.custom_logger import CustomLogger + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.proxy._experimental.mcp_server.server import ( + _fire_mcp_tool_call_logging, + ) + from litellm.types.mcp import MCPPostCallResponseObject + + class RedactingLogger(CustomLogger): + async def async_post_mcp_tool_call_hook( + self, + kwargs: dict[str, object], + response_obj: MCPPostCallResponseObject, + start_time: datetime, + end_time: datetime, + ) -> MCPPostCallResponseObject: + assert isinstance(response_obj.mcp_tool_call_response, list) + assert isinstance(response_obj.mcp_tool_call_response[0], TextContent) + response_obj.mcp_tool_call_response = [TextContent(type="text", text="[REDACTED]")] + return response_obj + + logging_obj = Logging( + model="MCP: weather/get_forecast", + messages=[{"role": "user", "content": "tool call"}], + stream=False, + call_type="call_mcp_tool", + start_time=datetime.now(), + litellm_call_id="test-mcp-hook-content", + function_id="test-fn", + dynamic_success_callbacks=[RedactingLogger()], + ) + proxy_logging_mock = _mock_mcp_proxy_logging() + result = CallToolResult( + content=[TextContent(type="text", text="SECRET-1234")], + structuredContent={"result": "SECRET-1234"}, + isError=False, + ) + + with patch( # test-quality-ok: [TQ008] inject proxy logging collaborator + "litellm.proxy.proxy_server.proxy_logging_obj", + proxy_logging_mock, + ): + hooked_result = await _fire_mcp_tool_call_logging( + logging_obj=logging_obj, + result=result, + start_time=datetime.now(), + end_time=datetime.now(), + user_api_key_auth=UserAPIKeyAuth(api_key="test-key", user_id="test-user"), + request_data={}, + ) + + assert isinstance(hooked_result.content[0], TextContent) + assert hooked_result.content[0].text == "[REDACTED]" + assert hooked_result.structured_content is None + assert hooked_result.is_error is True + + @pytest.mark.asyncio async def test_fire_mcp_tool_call_logging_iserror_without_auth_skips_failure_hook(): """Without a UserAPIKeyAuth the failure handlers still fire but the proxy @@ -8956,7 +8929,7 @@ async def test_fire_mcp_tool_call_logging_iserror_without_auth_skips_failure_hoo with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock): await _fire_mcp_tool_call_logging( logging_obj=logging_obj, - result={"isError": True, "content": [{"type": "text", "text": "denied"}]}, + result=_call_tool_result(True, "denied"), start_time=datetime.now(), end_time=datetime.now(), ) @@ -10260,7 +10233,10 @@ async def test_active_request_ctx_var_feeds_auth_resolution_recording(_mcp_reque ("1999-01-01", True), ], ) -async def test_streamable_http_rejects_modern_protocol_version(header_value: str, expected_rejected: bool) -> None: +@pytest.mark.parametrize("handler", ("handle_streamable_http_mcp", "handle_sse_mcp")) +async def test_streamable_http_rejects_modern_protocol_version( + header_value: str, expected_rejected: bool, handler: str +) -> None: from litellm.proxy._experimental.mcp_server import server as mcp_module from litellm.proxy._experimental.mcp_server.server import unsupported_protocol_version @@ -10283,7 +10259,7 @@ async def test_streamable_http_rejects_modern_protocol_version(header_value: str async def send(message: Message) -> None: sent.append(message) - await mcp_module.handle_streamable_http_mcp(scope, receive, send) + await getattr(mcp_module, handler)(scope, receive, send) start = next(m for m in sent if m["type"] == "http.response.start") assert start["status"] == 400 @@ -10333,3 +10309,124 @@ async def test_tool_listing_preserves_permission_denial_when_failure_logging_fai logger.post_call_failure_hook.assert_awaited_once() assert logger.post_call_failure_hook.await_args.kwargs["original_exception"] is denial assert logger.post_call_failure_hook.await_args.kwargs["user_api_key_dict"] == auth + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix,suffix", (("", ""), ("/gateway", "/"))) +async def test_legacy_sse_mount_emits_message_endpoint(prefix: str, suffix: str) -> None: + from starlette.applications import Starlette + from starlette.routing import Mount + from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing + from litellm.proxy._experimental.mcp_server import server as mcp_server + + app: Final = Starlette(routes=[Mount("/mcp", app=mcp_server.app)]) + incoming: Final[asyncio.Queue[Message]] = asyncio.Queue() + outgoing: Final[asyncio.Queue[Message]] = asyncio.Queue() + await incoming.put({"type": "http.request", "body": b"", "more_body": False}) + path: Final = f"{prefix}/mcp/sse{suffix}" + scope: Final[Scope] = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": prefix, + "server": ("localhost", 80), + "client": ("127.0.0.1", 1234), + "headers": [(b"accept", b"text/event-stream")], + } + auth: Final = UserAPIKeyAuth(api_key="test-owner") + with ( + patch.object( + mcp_server, "extract_mcp_auth_context", AsyncMock(return_value=(auth, None, None, None, None, None)) + ), + patch.object(mcp_server, "_raise_preemptive_401_for_unauthenticated_servers", AsyncMock()), + patch.object(mcp_server, "_check_passthrough_upstream_auth", AsyncMock()), + patch.object(mcp_server.operations, "_raise_if_initialize_grants_no_mcp_servers", AsyncMock()), + patch.object(mcp_server, "_SESSION_MANAGERS_INITIALIZED", True), + ): + task: Final = asyncio.create_task(app(scope, incoming.get, outgoing.put)) + try: + start: Final = await asyncio.wait_for(outgoing.get(), 2) + assert start["type"] == "http.response.start" + assert start["status"] == 200 + endpoint_frame: Final = await asyncio.wait_for(outgoing.get(), 2) + frame: Final = endpoint_frame["body"].decode() + assert "event: endpoint" in frame + endpoint: Final = frame.split("data: ", 1)[1].splitlines()[0] + assert endpoint.startswith(f"{prefix}/mcp/sse/messages?session_id=") + message_path, query = endpoint.split("?", 1) + + async def post(body: bytes) -> int: + messages: Final[asyncio.Queue[Message]] = asyncio.Queue() + requests: Final[asyncio.Queue[Message]] = asyncio.Queue() + await requests.put({"type": "http.request", "body": body, "more_body": False}) + post_scope: Final[Scope] = { + **scope, + "method": "POST", + "path": message_path + suffix, + "raw_path": (message_path + suffix).encode(), + "query_string": query.encode(), + "root_path": prefix, + "headers": [(b"content-type", b"application/json")], + } + await asyncio.wait_for(app(post_scope, requests.get, messages.put), 2) + return (await messages.get())["status"] + + initialization: Final = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": LATEST_HANDSHAKE_VERSION, + "capabilities": {}, + "clientInfo": {"name": "legacy-client", "version": "1"}, + }, + } + ).encode() + assert await post(initialization) == 202 + reply: Final = (await asyncio.wait_for(outgoing.get(), 2))["body"].decode() + initialized: Final = json.loads(reply.split("data: ", 1)[1].splitlines()[0]) + assert initialized["id"] == 1 + assert initialized["result"]["serverInfo"]["name"] == "litellm-mcp-server" + + assert await post(b'{"jsonrpc":"2.0","method":"notifications/initialized"}') == 202 + for request_id, marker in ((2, "first-post"), (3, "second-post")): + post_auth: Final = UserAPIKeyAuth(api_key="test-owner", user_id=marker) + listing: Final = AsyncMock(return_value=AggregateToolListing(tools=[], outcomes={})) + with ( + patch.object( + mcp_server, + "extract_mcp_auth_context", + AsyncMock(return_value=(post_auth, None, [marker], {marker: {"Authorization": marker}}, {"Authorization": marker}, {"x-request-marker": marker})), + ), + patch.object(mcp_server.operations, "_get_tools_from_mcp_servers", listing), + ): + assert ( + await post(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "tools/list"}).encode()) + == 202 + ) + listed_frame: Final = (await asyncio.wait_for(outgoing.get(), 2))["body"].decode() + listed: Final = json.loads(listed_frame.split("data: ", 1)[1].splitlines()[0]) + assert listed["id"] == request_id + assert listed["result"]["tools"] == [] + listing.assert_awaited_once() + assert listing.await_args.kwargs["user_api_key_auth"].user_id == marker + assert listing.await_args.kwargs["mcp_servers"] == [marker] + assert listing.await_args.kwargs["mcp_server_auth_headers"] == {marker: {"Authorization": marker}} + assert listing.await_args.kwargs["oauth2_headers"] == {"Authorization": marker} + assert listing.await_args.kwargs["raw_headers"] == {"x-request-marker": marker} + + stranger: Final = UserAPIKeyAuth(api_key="different-owner") + with patch.object( + mcp_server, "extract_mcp_auth_context", AsyncMock(return_value=(stranger, None, None, None, None, None)) + ): + assert await post(initialization) == 404 + finally: + await incoming.put({"type": "http.disconnect"}) + await asyncio.wait_for(task, 2) + assert await post(initialization) == 404 diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 14b739e60ba..ffba53a5b7a 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -8057,7 +8057,6 @@ def test_model_has_no_cost_mapping_no_model_or_router_is_false(): [ "azure/speech/azure-tts", "mistral/mistral-ocr-latest", - "vertex_ai/imagen-3.0-generate-001", "dashscope/qwen-flash", ], ) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 8da93ba341b..e5179387f82 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -653,6 +653,10 @@ def test_virtual_key_llm_api_routes_denies_spend_logs_v2(): "/mcp-rest/tools/call", "/mcp/tools/list", "/token", + "/mcp/sse", + "/mcp/sse/", + "/mcp/sse/messages", + "/mcp/sse/messages/", ], ) def test_mcp_inference_routes_classified_as_llm_api(route): @@ -4310,3 +4314,26 @@ def test_project_delete_route_stays_proxy_admin_only(): valid_token=valid_token, request_data={}, ) + + +@pytest.mark.parametrize("route", ("/mcp/sse", "/mcp/sse/", "/mcp/sse/messages", "/mcp/sse/messages/")) +@pytest.mark.parametrize("route_group", ("mcp_routes", "llm_api_routes", "openai_routes")) +def test_legacy_sse_respects_virtual_key_route_permissions(route: str, route_group: str) -> None: + token: Final = UserAPIKeyAuth( + user_id="sse-caller", user_role=LitellmUserRoles.INTERNAL_USER, allowed_routes=[route_group] + ) + request: Final = Request({"type": "http", "method": "POST" if "messages" in route else "GET", "path": route}) + if route_group == "openai_routes": + with pytest.raises(HTTPException) as caught: + RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=token, request=request) + assert caught.value.status_code == 403 + return + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=None, + _user_role=LitellmUserRoles.INTERNAL_USER, + route=route, + request=request, + valid_token=token, + request_data={}, + ) + assert RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=token, request=request) diff --git a/tests/test_litellm/proxy/common_utils/test_config_sync_pubsub.py b/tests/test_litellm/proxy/common_utils/test_config_sync_pubsub.py index 2a0ebf9492a..0f64ef2b4ca 100644 --- a/tests/test_litellm/proxy/common_utils/test_config_sync_pubsub.py +++ b/tests/test_litellm/proxy/common_utils/test_config_sync_pubsub.py @@ -47,6 +47,7 @@ _EXPECTED_CONFIG_SYNCED_TABLE_NAMES = frozenset( "litellm_proxymodeltable", "litellm_searchtoolstable", "litellm_ssoconfig", + "litellm_uisettings", } ) @@ -702,6 +703,33 @@ async def test_model_repository_write_publishes_via_live_coordination_cache() -> assert json.loads(message) == {"object_type": "litellm_proxymodeltable"} +async def test_ui_settings_write_publishes_via_live_coordination_cache() -> None: + from litellm.proxy import proxy_server + from litellm.proxy.proxy_server import _set_redis_usage_cache + from litellm.repositories.table_repositories import UISettingsRepository + + client = _RecordingRedisClient() + prisma_client = MagicMock() + prisma_client.db.litellm_uisettings.upsert = AsyncMock(return_value={"id": "ui_settings"}) + table = UISettingsRepository(prisma_client).table + assert isinstance(table, _PublishOnWriteActions) + + previous_cache = proxy_server.redis_usage_cache + _set_redis_usage_cache(_FakeRedisCache(client)) + try: + await table.upsert( + where={"id": "ui_settings"}, + data={"create": {"id": "ui_settings"}, "update": {"ui_settings": "{}"}}, + ) + finally: + _set_redis_usage_cache(previous_cache) + + assert len(client.published) == 1 + channel, message = client.published[0] + assert channel == CONFIG_SYNC_CHANNEL + assert json.loads(message) == {"object_type": "litellm_uisettings"} + + async def _publish_calls_for_invalidated_param(param_name: str) -> List[Tuple[str, str]]: from litellm.proxy import proxy_server from litellm.proxy.proxy_server import _set_redis_usage_cache diff --git a/tests/test_litellm/proxy/common_utils/test_debug_utils.py b/tests/test_litellm/proxy/common_utils/test_debug_utils.py index 163ea530be9..a64c94c2991 100644 --- a/tests/test_litellm/proxy/common_utils/test_debug_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_debug_utils.py @@ -1,16 +1,25 @@ +import json import os import socket +from collections.abc import Iterator, Mapping +from dataclasses import asdict from pathlib import Path import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy import proxy_server +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.bug_report_config import build_proxy_bug_report from litellm.proxy.common_utils.debug_utils import ( PSUTIL_MISSING_ERROR, _ProcFilesystemProcess, _summary_process_memory, get_memory_summary, ) +from litellm.proxy.common_utils.debug_utils import router as debug_router PAGE_SIZE = 4096 STATM_SIZE_PAGES = 100_000 @@ -67,3 +76,73 @@ async def test_memory_summary_names_the_host_and_worker_that_answered() -> None: assert summary["hostname"] == socket.gethostname() assert summary["worker_pid"] == os.getpid() assert summary["memory"]["ram_usage_mb"] > 0 + + +HOSTILE_CONFIG: Mapping[str, object] = { + "model_list": [ + { + "model_name": "acme-prod-gpt4", + "litellm_params": { + "model": "azure/acme-gpt4o-deployment", + "api_base": "https://acme-eastus.openai.azure.com", + "api_key": "sk-live-secret-1", + }, + } + ], + "litellm_settings": {"drop_params": True, "callbacks": ["langfuse", "acme_hooks.audit_logger"]}, +} + +HOSTILE_GENERAL_SETTINGS: Mapping[str, object] = { + "master_key": "sk-live-secret-master", + "database_url": "postgres://user:hunter2@10.0.0.7/litellm", + "store_model_in_db": True, +} + +HOSTILE_STRINGS = ("acme", "sk-live-secret", "hunter2", "10.0.0.7", "azure.com") + + +@pytest.fixture +def hostile_proxy_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + previous_config = proxy_server.proxy_config.get_config_state() + proxy_server.proxy_config.update_config_state(config=HOSTILE_CONFIG) + monkeypatch.setattr(proxy_server, "general_settings", dict(HOSTILE_GENERAL_SETTINGS)) + yield + proxy_server.proxy_config.update_config_state(config=previous_config) + + +def _debug_client(caller: UserAPIKeyAuth) -> TestClient: + app = FastAPI() + app.include_router(debug_router) + app.dependency_overrides[user_api_key_auth] = lambda: caller + return TestClient(app) + + +@pytest.mark.parametrize( + "caller", + [ + UserAPIKeyAuth(), + UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER), + UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + ], +) +@pytest.mark.usefixtures("hostile_proxy_config") +def test_debug_report_refuses_everyone_but_proxy_admins(caller: UserAPIKeyAuth) -> None: + response = _debug_client(caller).get("/debug/report") + + assert response.status_code == 403, response.text + assert "litellm_version" not in response.text + + +@pytest.mark.usefixtures("hostile_proxy_config") +def test_debug_report_returns_what_the_bug_report_link_carries_and_nothing_from_the_operator() -> None: + response = _debug_client(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)).get("/debug/report") + + assert response.status_code == 200, response.text + assert response.json() == json.loads(json.dumps(asdict(build_proxy_bug_report(RuntimeError("boom")).environment))) + assert response.json()["config_lines"] == [ + "general_settings.store_model_in_db = true", + "litellm_settings.drop_params = true", + "litellm_settings.callbacks = [langfuse]", + "model_list[*].provider = [azure]", + ] + assert not any(hostile in response.text for hostile in HOSTILE_STRINGS), response.text diff --git a/tests/test_litellm/proxy/common_utils/test_swagger_utils.py b/tests/test_litellm/proxy/common_utils/test_swagger_utils.py new file mode 100644 index 00000000000..659d2ad2941 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_swagger_utils.py @@ -0,0 +1,18 @@ +import inspect + +from litellm.exceptions import RateLimitError +from litellm.proxy.common_utils.swagger_utils import ERROR_RESPONSES, _error_description + + +class _ChildWithoutDoc(RateLimitError): + pass + + +def test_error_response_descriptions_carry_no_docstring_indentation(): + assert ERROR_RESPONSES[429]["description"] == inspect.cleandoc(RateLimitError.__doc__ or "") + for response in ERROR_RESPONSES.values(): + assert response["description"] == inspect.cleandoc(response["description"]) + + +def test_error_description_falls_back_to_the_class_name_without_an_own_docstring(): + assert _error_description(_ChildWithoutDoc) == "_ChildWithoutDoc" diff --git a/tests/test_litellm/proxy/db/test_pgbouncer.py b/tests/test_litellm/proxy/db/test_pgbouncer.py index bf7df3077ea..c69c8d015a3 100644 --- a/tests/test_litellm/proxy/db/test_pgbouncer.py +++ b/tests/test_litellm/proxy/db/test_pgbouncer.py @@ -122,6 +122,13 @@ class TestPlanPgBouncer: "pgbouncer": "true", } + def test_an_upstream_that_already_disables_prepared_statements_gets_a_single_pgbouncer_flag(self): + pooled: Final = _plan("postgresql://app:pw@db/litellm?connection_limit=5&pgbouncer=true").pooled_url + assert urllib.parse.parse_qsl(urllib.parse.urlsplit(pooled).query) == [ + ("connection_limit", "5"), + ("pgbouncer", "true"), + ] + @pytest.mark.parametrize("hop_param", ["channel_binding=require", "gssencmode=require"]) def test_transport_params_for_the_postgres_hop_stay_off_the_plain_tcp_loopback_url(self, hop_param: str): pooled: Final = _plan(f"postgresql://app:pw@db/litellm?connection_limit=5&{hop_param}").pooled_url diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_cisco_ai_defense_mcp.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_cisco_ai_defense_mcp.py index 826edab694d..2e3bf760e68 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_cisco_ai_defense_mcp.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_cisco_ai_defense_mcp.py @@ -827,3 +827,38 @@ class TestCiscoAIDefenseJsonRpcSuccessEnvelope: assert unwrapped is verdict else: assert unwrapped == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("action", ["block", "redact"]) +async def test_cisco_native_hook_through_logging_preserves_sanitized_result(action): + from mcp.types import CallToolResult, TextContent + from litellm.litellm_core_utils.litellm_logging import Logging + + guardrail = _make_guardrail( + inspection_type="mcp", event_hook=["pre_mcp_call", "during_mcp_call"] + ) + verdict = ( + _violation_response(url=MCP_URL) if action == "block" + else _redact_response(sanitized_text="[REDACTED]", url=MCP_URL) + ) + result = CallToolResult( + content=[TextContent(type="text", text="SECRET-1234")], + structured_content={"result": "SECRET-1234"}, + ) + logging_obj = Logging( + model="MCP: probe/search", messages=[], stream=False, call_type="call_mcp_tool", + start_time=datetime.now(), litellm_call_id="cisco-hook", function_id="cisco-hook", + dynamic_success_callbacks=[guardrail], + ) + logging_obj.model_call_details.update({"name": "search", "arguments": {}, "original_response": result}) + with _patch_inspection_post(guardrail, AsyncMock(return_value=verdict)): + returned = await logging_obj.async_post_mcp_tool_call_hook( + kwargs=logging_obj.model_call_details, response_obj=result, + start_time=datetime.now(), end_time=datetime.now(), + ) + assert returned is result + assert "SECRET-1234" not in returned.model_dump_json() + assert returned.is_error is (action == "block") + assert ("Blocked by Cisco AI Defense" if action == "block" else "[REDACTED]") in returned.content[0].text + assert returned.structured_content == {"result": returned.content[0].text} diff --git a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py index 76027d6b7e2..7a9d155cd67 100644 --- a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py +++ b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py @@ -701,3 +701,49 @@ class TestKeyUpdatedAuditLogObjectId: assert updated_values["project_id"] is None assert json.loads(audit_row.before_value)["project_id"] == "project-orbit" assert updated_values["max_budget"] == 2000.0 + + +@pytest.mark.asyncio +async def test_key_deleted_hook_writes_audit_log_for_alias_deletion(): + from litellm.proxy._types import ( + KeyRequest, + LiteLLM_AuditLogs, + LiteLLM_VerificationToken, + LitellmTableNames, + UserAPIKeyAuth, + ) + + captured: Final[list[LiteLLM_AuditLogs]] = [] + + async def capture_audit_log(request_data: LiteLLM_AuditLogs) -> None: + captured.append(request_data) + + with ( + patch("litellm.store_audit_logs", True), + patch( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + new=capture_audit_log, + ), + patch.object( + KeyManagementEventHooks, + "_delete_virtual_keys_from_secret_manager", + new_callable=AsyncMock, + ), + ): + await KeyManagementEventHooks.async_key_deleted_hook( + data=KeyRequest(key_aliases=["a"]), + keys_being_deleted=[LiteLLM_VerificationToken(token="hashed", key_alias="a")], + response={}, + user_api_key_dict=UserAPIKeyAuth(user_id="admin", token="callertok"), + ) + for _ in range(100): + if captured: + break + await asyncio.sleep(0.01) + + assert len(captured) == 1 + audit_row = captured[0] + assert audit_row.action == "deleted" + assert audit_row.object_id == "hashed" + assert audit_row.table_name == LitellmTableNames.KEY_TABLE_NAME + assert audit_row.changed_by == "admin" diff --git a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py index 0f19675edb9..8c0dcd3383c 100644 --- a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py +++ b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py @@ -26,6 +26,7 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import ( PARALLEL_REQUEST_SLOT_TTL_SECONDS, ParallelSlotAcquisition, RateLimitDescriptor, + RateLimitedModel, RateLimitResponse, RequestRateLimiterStash, _request_stash, @@ -3367,7 +3368,7 @@ async def test_pre_call_hook_keeps_internal_stash_out_of_request_body(): stash = get_request_stash() assert stash is not None assert stash.reserved_tokens > 0 - assert stash.reserved_model == "gpt-4o-mini" + assert stash.reserved_model == RateLimitedModel(requested="gpt-4o-mini", group="gpt-4o-mini") assert stash.reserved_scopes == frozenset({("api_key", _api_key)}) @@ -6965,3 +6966,187 @@ def test_rate_limit_error_reports_reset_time_in_utc_on_a_non_utc_proxy(process_t "Rate limit exceeded for api_key: sk-test. Limit type: requests. " f"Current limit: 2, Remaining: 0. Limit resets at: {expected_reset}" ) + + +def _resolve_alias_to_target(model: str) -> str | None: + return "target" if model == "alias" else None + + +async def _rpm_request(handler: _PROXY_MaxParallelRequestsHandler, cache: DualCache, auth: UserAPIKeyAuth, model: str) -> None: + await handler.async_pre_call_hook(user_api_key_dict=auth, cache=cache, data={"model": model}, call_type="acompletion") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("first_name, second_name", [("target", "alias"), ("alias", "target")]) +async def test_model_group_alias_shares_deployment_default_rpm_bucket_with_its_target( + monkeypatch: pytest.MonkeyPatch, first_name: str, second_name: str +) -> None: + import litellm.proxy.proxy_server as proxy_server + + router: Final = Router( + model_list=[ + { + "model_name": "target", + "litellm_params": {"model": "openai/gpt-test", "api_key": "test-key", "default_api_key_rpm_limit": 2}, + "model_info": {"id": "target-deployment"}, + } + ], + model_group_alias={"alias": "target"}, + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + cache: Final = DualCache() + handler: Final = _PROXY_MaxParallelRequestsHandler(internal_usage_cache=InternalUsageCache(cache)) + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-alias-default")) + + await _rpm_request(handler, cache, auth, first_name) + await _rpm_request(handler, cache, auth, first_name) + with pytest.raises(HTTPException) as exc: + await _rpm_request(handler, cache, auth, second_name) + + assert exc.value.status_code == 429 + assert "model_per_key" in str(exc.value.detail) + assert f"{auth.api_key}:target" in str(exc.value.detail) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("first_name, second_name", [("target", "alias"), ("alias", "target")]) +@pytest.mark.parametrize( + "limits, counter_scope", + [ + ({"metadata": {"model_rpm_limit": {"target": 1}}}, "model_per_key"), + ( + { + "team_id": "t", + "metadata": {"model_rpm_limit": {"other-model": 100}}, + "team_metadata": {"model_rpm_limit": {"target": 1}}, + }, + "model_per_team", + ), + ({"org_id": "o", "organization_metadata": {"model_rpm_limit": {"target": 1}}}, "model_per_organization"), + ({"project_id": "p", "project_metadata": {"model_rpm_limit": {"target": 1}}}, "model_per_project"), + ], + ids=["key_metadata", "team_metadata", "organization_metadata", "project_metadata"], +) +async def test_model_group_alias_shares_metadata_model_rpm_bucket_with_its_target( + limits: dict[str, object], counter_scope: str, first_name: str, second_name: str +) -> None: + cache: Final = DualCache() + handler: Final = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(cache), model_group_resolver=_resolve_alias_to_target + ) + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-alias-metadata"), **limits) + + await _rpm_request(handler, cache, auth, first_name) + with pytest.raises(HTTPException) as exc: + await _rpm_request(handler, cache, auth, second_name) + + assert exc.value.status_code == 429 + assert counter_scope in str(exc.value.detail) + assert ":target" in str(exc.value.detail) + + +@pytest.mark.asyncio +async def test_model_rpm_limit_keyed_by_the_alias_name_still_limits_alias_requests_only() -> None: + cache: Final = DualCache() + handler: Final = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(cache), model_group_resolver=_resolve_alias_to_target + ) + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-alias-keyed"), metadata={"model_rpm_limit": {"alias": 1}}) + + await _rpm_request(handler, cache, auth, "alias") + with pytest.raises(HTTPException) as exc: + await _rpm_request(handler, cache, auth, "alias") + assert exc.value.status_code == 429 + assert "model_per_key" in str(exc.value.detail) + + await _rpm_request(handler, cache, auth, "target") + + +@pytest.mark.parametrize( + "key_metadata, charges_team_model_pool", + [({}, True), ({"model_tpm_limit": {"target": 500}}, False)], + ids=["no_key_override", "key_owns_target_tpm_limit"], +) +def test_success_tpm_accounting_charges_the_alias_target_bucket( + key_metadata: dict[str, object], charges_team_model_pool: bool +) -> None: + handler: Final = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(DualCache()), model_group_resolver=_resolve_alias_to_target + ) + response: Final = ModelResponse( + id="alias-tpm", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="alias", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + kwargs: Final = { + "standard_logging_object": { + "metadata": {"user_api_key_hash": hash_token("sk-alias-tpm"), "user_api_key_team_id": "t"} + }, + "litellm_params": { + "metadata": { + "model_group": "alias", + "user_api_key_metadata": key_metadata, + "user_api_key_team_metadata": {"model_tpm_limit": {"target": 500}}, + } + }, + "model": "alias", + } + + ops: Final = handler._build_success_event_pipeline_operations( + kwargs=kwargs, response_obj=response, rate_limit_type="output" + ) + + charged_keys: Final = {op["key"] for op in ops} + assert handler.create_rate_limit_keys("model_per_key", f"{hash_token('sk-alias-tpm')}:target", "tokens") in charged_keys + assert not any(":alias" in key for key in charged_keys) + team_pool_key: Final = handler.create_rate_limit_keys("model_per_team", "t:target", "tokens") + assert (team_pool_key in charged_keys) is charges_team_model_pool + + +@pytest.mark.asyncio +async def test_success_tpm_accounting_keeps_the_admission_target_after_an_alias_reload() -> None: + alias_map: Final[dict[str, str]] = {"alias": "target-a"} + cache: Final = DualCache() + handler: Final = _PROXY_MaxParallelRequestsHandler( + internal_usage_cache=InternalUsageCache(cache), model_group_resolver=alias_map.get + ) + key_metadata: Final = {"model_tpm_limit": {"target-a": 1000, "target-b": 1000}} + auth: Final = UserAPIKeyAuth(api_key=hash_token("sk-alias-reload"), metadata=key_metadata) + + await handler.async_pre_call_hook( + user_api_key_dict=auth, + cache=cache, + data={"model": "alias", "messages": [{"role": "user", "content": "hello"}], "max_tokens": 10}, + call_type="acompletion", + ) + stash: Final = get_request_stash() + assert stash is not None + assert stash.reserved_model == RateLimitedModel(requested="alias", group="target-a") + assert stash.reserved_tokens > 0 + + alias_map["alias"] = "target-b" + response: Final = ModelResponse( + id="alias-reload", + object="chat.completion", + created=int(datetime.now().timestamp()), + model="alias", + usage=Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150), + choices=[], + ) + kwargs: Final = { + "standard_logging_object": {"metadata": {"user_api_key_hash": auth.api_key}}, + "litellm_params": {"metadata": {"model_group": "alias", "user_api_key_metadata": key_metadata}}, + "model": "alias", + } + + ops: Final = handler._build_success_event_pipeline_operations( + kwargs=kwargs, response_obj=response, rate_limit_type="total" + ) + + admission_bucket: Final = handler.create_rate_limit_keys("model_per_key", f"{auth.api_key}:target-a", "tokens") + charged: Final = {op["key"]: op["increment_value"] for op in ops} + assert charged[admission_bucket] == 150 - stash.reserved_tokens + assert not any(":target-b" in key for key in charged) diff --git a/tests/test_litellm/proxy/hooks/test_tpm_concurrent.py b/tests/test_litellm/proxy/hooks/test_tpm_concurrent.py index bdaca9ffc2d..e6795bb22f3 100644 --- a/tests/test_litellm/proxy/hooks/test_tpm_concurrent.py +++ b/tests/test_litellm/proxy/hooks/test_tpm_concurrent.py @@ -25,6 +25,7 @@ from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.hooks.parallel_request_limiter_v3 import ( PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY, + RateLimitedModel, _AUDIO_BYTES_PER_TOKEN, _PROXY_MaxParallelRequestsHandler_v3 as RateLimitHandler, ) @@ -308,7 +309,7 @@ async def test_model_scope_refund_targets_reserved_model(rate_limiter): stash = get_or_create_request_stash() stash.reserved_tokens = 100 - stash.reserved_model = reserved_model + stash.reserved_model = RateLimitedModel(requested=reserved_model, group=reserved_model) stash.reserved_scopes = frozenset({("model_per_team", f"{team_id}:{reserved_model}")}) mock_kwargs = { diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index f684e2040bd..2d1049b143e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import json import logging @@ -4779,3 +4780,62 @@ def test_user_routes_answer_503_no_db_connection_when_the_callers_user_read_hits assert response.status_code == 503, response.text assert response.json() == _DB_OUTAGE_503_BODY + + +@pytest.mark.asyncio +async def test_delete_user_writes_deleted_audit_log_for_user_keys(mocker): + from litellm.proxy._types import ( + DeleteUserRequest, + LiteLLM_VerificationToken, + LitellmTableNames, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.internal_user_endpoints import delete_user + + mock_prisma_client = mocker.MagicMock() + + mock_user_row = mocker.MagicMock() + mock_user_row.user_id = "doomed-user" + mock_user_row.user_email = "doomed@example.com" + mock_user_row.teams = [] + mock_user_row.model_dump_json.return_value = "{}" + mock_user_row.model_dump.return_value = {"user_id": "doomed-user", "user_email": "doomed@example.com", "teams": []} + + mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(return_value=mock_user_row) + mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(return_value=[]) + + user_key = LiteLLM_VerificationToken(token="hashed-user-key", user_id="doomed-user") + mock_prisma_client.db.litellm_verificationtoken.find_many = mocker.AsyncMock(return_value=[user_key]) + mock_prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(return_value=1) + mock_prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(return_value=0) + mock_prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(return_value=0) + mock_prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(return_value=0) + mock_prisma_client.db.litellm_jwtkeymapping.find_many = mocker.AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1) + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.store_audit_logs", True) + captured: Final[list] = [] + + async def _capture(request_data): + captured.append(request_data) + + mocker.patch( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + new=_capture, + ) + + caller = UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN) + await delete_user(data=DeleteUserRequest(user_ids=["doomed-user"]), user_api_key_dict=caller) + for _ in range(100): + if captured: + break + await asyncio.sleep(0.01) + + key_rows: Final = [r for r in captured if r.table_name == LitellmTableNames.KEY_TABLE_NAME] + assert len(key_rows) == 1 + audit_row: Final = key_rows[0] + assert audit_row.action == "deleted" + assert audit_row.object_id == user_key.token + assert audit_row.changed_by + assert json.loads(audit_row.before_value)["token"] == user_key.token diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 8eaa4901c59..c539a10e1e2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -609,10 +609,7 @@ async def test_generate_key_debug_log_never_contains_raw_token(monkeypatch, capl mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) from litellm.proxy._types import GenerateKeyRequest, LitellmUserRoles from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth @@ -1494,18 +1491,12 @@ async def test_list_keys_full_object_returns_lifetime_total_spend(): @pytest.mark.asyncio async def test_get_new_token_with_valid_key(monkeypatch): """Test get_new_token function when provided with a valid key that starts with 'sk-'""" - from unittest.mock import AsyncMock - from litellm.proxy._types import RegenerateKeyRequest from litellm.proxy.management_endpoints.key_management_endpoints import ( get_new_token, ) - # Mock get_ui_settings_cached to return setting disabled (custom keys allowed) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) # Test with valid new_key data = RegenerateKeyRequest(new_key="sk-test1234567890abc") @@ -1517,8 +1508,6 @@ async def test_get_new_token_with_valid_key(monkeypatch): @pytest.mark.asyncio async def test_get_new_token_with_invalid_key(monkeypatch): """Test get_new_token function when provided with an invalid key that doesn't start with 'sk-'""" - from unittest.mock import AsyncMock - from fastapi import HTTPException from litellm.proxy._types import RegenerateKeyRequest @@ -1526,11 +1515,7 @@ async def test_get_new_token_with_invalid_key(monkeypatch): get_new_token, ) - # Mock get_ui_settings_cached to return setting disabled (custom keys allowed) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) # Test with invalid new_key (doesn't start with 'sk-') data = RegenerateKeyRequest(new_key="invalid-key-123") @@ -1546,8 +1531,6 @@ async def test_get_new_token_with_invalid_key(monkeypatch): async def test_get_new_token_rejects_short_new_key(monkeypatch): """Regression test for LIT-4355: a short custom key like sk-99 must be rejected, otherwise the stored key_name (sk-...{last 4 chars}) reveals the entire key.""" - from unittest.mock import AsyncMock - from fastapi import HTTPException from litellm.proxy._types import RegenerateKeyRequest @@ -1555,10 +1538,7 @@ async def test_get_new_token_rejects_short_new_key(monkeypatch): get_new_token, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) data = RegenerateKeyRequest(new_key="sk-99") @@ -1588,10 +1568,7 @@ async def test_generate_key_fn_rejects_short_custom_key(monkeypatch, short_key): ) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) assert len(short_key) < 16 @@ -1628,10 +1605,7 @@ async def test_generate_key_fn_accepts_custom_key_at_minimum_length(monkeypatch) ) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) custom_key = "sk-abcdefghijklm" assert len(custom_key) == 16 @@ -1649,18 +1623,13 @@ async def test_generate_key_fn_accepts_custom_key_at_minimum_length(monkeypatch) @pytest.mark.asyncio async def test_check_custom_key_allowed_when_disabled(monkeypatch): """_check_custom_key_allowed raises 403 when disable_custom_api_keys is true.""" - from unittest.mock import AsyncMock - from fastapi import HTTPException from litellm.proxy.management_endpoints.key_management_endpoints import ( _check_custom_key_allowed, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={"disable_custom_api_keys": True}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"disable_custom_api_keys": True}) with pytest.raises(HTTPException) as exc_info: await _check_custom_key_allowed("sk-custom-key-123") @@ -1672,16 +1641,11 @@ async def test_check_custom_key_allowed_when_disabled(monkeypatch): @pytest.mark.asyncio async def test_check_custom_key_allowed_when_enabled(monkeypatch): """_check_custom_key_allowed does nothing when disable_custom_api_keys is false.""" - from unittest.mock import AsyncMock - from litellm.proxy.management_endpoints.key_management_endpoints import ( _check_custom_key_allowed, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={"disable_custom_api_keys": False}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"disable_custom_api_keys": False}) # Should not raise await _check_custom_key_allowed("sk-custom-key-123") @@ -1690,35 +1654,133 @@ async def test_check_custom_key_allowed_when_enabled(monkeypatch): @pytest.mark.asyncio async def test_check_custom_key_allowed_when_unset(monkeypatch): """_check_custom_key_allowed does nothing when setting is not present.""" - from unittest.mock import AsyncMock - from litellm.proxy.management_endpoints.key_management_endpoints import ( _check_custom_key_allowed, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}) # Should not raise await _check_custom_key_allowed("sk-custom-key-123") @pytest.mark.asyncio -async def test_check_custom_key_allowed_none_key_always_passes(monkeypatch): - """_check_custom_key_allowed does nothing when key is None, even if setting is on.""" - from unittest.mock import AsyncMock +async def test_check_custom_key_allowed_honours_the_config_file(monkeypatch): + """A config-file general_settings.disable_custom_api_keys is enforced with no stored UI row.""" + from fastapi import HTTPException + from litellm.proxy.config_resolvers import SettingsStore from litellm.proxy.management_endpoints.key_management_endpoints import ( _check_custom_key_allowed, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={"disable_custom_api_keys": True}), + general_settings = SettingsStore("general_settings") + general_settings.load_yaml({"disable_custom_api_keys": True}) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) + + with pytest.raises(HTTPException) as exc_info: + await _check_custom_key_allowed("sk-custom-key-123456") + + assert exc_info.value.status_code == 403 + + +@pytest.mark.parametrize( + ("config_value", "blocked"), + [ + (True, True), + ("true", True), + ("True", True), + (1, True), + (False, False), + ("false", False), + ("False", False), + (0, False), + ], +) +@pytest.mark.asyncio +async def test_check_custom_key_allowed_coerces_a_non_bool_config_value(monkeypatch, config_value, blocked): + """A YAML value that is not a bare bool, such as a quoted "true", still decides the gate.""" + from fastapi import HTTPException + + from litellm.proxy.config_resolvers import SettingsStore + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, ) + general_settings = SettingsStore("general_settings") + general_settings.load_yaml({"disable_custom_api_keys": config_value}) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) + + rejected = False + try: + await _check_custom_key_allowed("sk-custom-key-123456") + except HTTPException as e: + rejected = e.status_code == 403 + + assert rejected is blocked + + +@pytest.mark.asyncio +async def test_check_custom_key_allowed_config_file_beats_the_stored_ui_row(monkeypatch): + """The config file owns the flag, so a stored UI row saying false cannot re-open custom keys.""" + from fastapi import HTTPException + + from litellm.proxy.config_resolvers import SettingsStore + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + apply_runtime_general_settings_flags, + ) + + general_settings = SettingsStore("general_settings") + general_settings.load_yaml({"disable_custom_api_keys": True}) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) + + apply_runtime_general_settings_flags({"disable_custom_api_keys": False}) + + with pytest.raises(HTTPException) as exc_info: + await _check_custom_key_allowed("sk-custom-key-123456") + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_check_custom_key_allowed_picks_up_a_ui_write_without_the_serving_pod(monkeypatch): + """A pod that never served the PATCH enforces the new value after its own settings sync.""" + from fastapi import HTTPException + + from litellm.proxy.config_resolvers import SettingsStore + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + apply_runtime_general_settings_flags, + ) + + general_settings = SettingsStore("general_settings") + general_settings.load_yaml({}) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) + + await _check_custom_key_allowed("sk-custom-key-123456") + + apply_runtime_general_settings_flags({"disable_custom_api_keys": True}) + + with pytest.raises(HTTPException) as exc_info: + await _check_custom_key_allowed("sk-custom-key-123456") + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_check_custom_key_allowed_none_key_always_passes(monkeypatch): + """_check_custom_key_allowed does nothing when key is None, even if setting is on.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _check_custom_key_allowed, + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"disable_custom_api_keys": True}) + # Should not raise — None means auto-generate await _check_custom_key_allowed(None) @@ -1726,8 +1788,6 @@ async def test_check_custom_key_allowed_none_key_always_passes(monkeypatch): @pytest.mark.asyncio async def test_get_new_token_rejected_when_custom_keys_disabled(monkeypatch): """get_new_token raises 403 when new_key is set and disable_custom_api_keys is true.""" - from unittest.mock import AsyncMock - from fastapi import HTTPException from litellm.proxy._types import RegenerateKeyRequest @@ -1735,10 +1795,7 @@ async def test_get_new_token_rejected_when_custom_keys_disabled(monkeypatch): get_new_token, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={"disable_custom_api_keys": True}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"disable_custom_api_keys": True}) data = RegenerateKeyRequest(new_key="sk-custom-regen-key") @@ -1751,17 +1808,12 @@ async def test_get_new_token_rejected_when_custom_keys_disabled(monkeypatch): @pytest.mark.asyncio async def test_get_new_token_auto_generates_when_custom_keys_disabled(monkeypatch): """get_new_token auto-generates a key when new_key is None, even if setting is on.""" - from unittest.mock import AsyncMock - from litellm.proxy._types import RegenerateKeyRequest from litellm.proxy.management_endpoints.key_management_endpoints import ( get_new_token, ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached", - AsyncMock(return_value={"disable_custom_api_keys": True}), - ) + monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"disable_custom_api_keys": True}) data = RegenerateKeyRequest() # no new_key result = await get_new_token(data) diff --git a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py index 3fcda310435..ec1af0518c6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_router_settings_endpoints.py @@ -6,20 +6,22 @@ Tests the GET endpoints for router settings and router fields. from collections.abc import Mapping from typing import Any, Final -from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient - from litellm.proxy import proxy_server from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.config_resolvers import SettingsStore from litellm.proxy.management_endpoints.router_settings_endpoints import ( + RouterFieldsResponse, + RouterSettingsResponse, + get_router_fields, get_router_settings, ) -from litellm.proxy.config_resolvers import SettingsStore from litellm.proxy.proxy_server import app from litellm.router import Router +from litellm.types.router import RoutingGroup client = TestClient(app) @@ -157,3 +159,91 @@ class TestRouterSettingsEndpoints: rg_field = next(f for f in response.fields if f.field_name == "routing_groups") assert rg_field.field_value == groups + + @pytest.mark.asyncio + @pytest.mark.parametrize("metadata_only", (True, False)) + async def test_priority_is_advertised_for_groups_only( + self, monkeypatch: pytest.MonkeyPatch, metadata_only: bool + ) -> None: + monkeypatch.setattr(proxy_server, "llm_router", None) + monkeypatch.setattr( + proxy_server, + "proxy_config", + _StubProxyConfig(SettingsStore("router_settings"), {}), + ) + admin_user: Final = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test" + ) + + response: Final[RouterFieldsResponse | RouterSettingsResponse] = ( + await get_router_fields(user_api_key_dict=admin_user) + if metadata_only + else await get_router_settings(user_api_key_dict=admin_user) + ) + + global_options: Final = next( + field.options + for field in response.fields + if field.field_name == "routing_strategy" + ) + assert global_options is not None + assert "priority" not in global_options + assert response.model_dump(mode="json")["routing_group_strategies"] == [*global_options, "priority"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("from_config", (False, True)) + async def test_settings_retains_explicit_model_priorities( + self, monkeypatch: pytest.MonkeyPatch, from_config: bool + ) -> None: + group: Final = RoutingGroup( + group_name="ordered-chat", + models=["primary", "backup"], + routing_strategy="priority", + model_priorities={"primary": 1, "backup": 2}, + ) + llm_router: Final = Router( + model_list=[ + { + "model_name": model, + "litellm_params": { + "model": "openai/gpt-5.4-nano", + "api_key": "sk-test", + }, + } + for model in group.models + ], + routing_groups=[group], + ) + expected: Final = [ + { + "group_name": "ordered-chat", + "models": ["primary", "backup"], + "routing_strategy": "priority", + "routing_strategy_args": None, + "model_priorities": ( + {"primary": 8, "backup": 3} + if from_config + else {"primary": 1, "backup": 2} + ), + } + ] + config: Final = {"routing_groups": expected} if from_config else {} + store: Final = SettingsStore("router_settings") + store.load_yaml(config) + monkeypatch.setattr(proxy_server, "llm_router", llm_router) + monkeypatch.setattr( + proxy_server, "proxy_config", _StubProxyConfig(store, config) + ) + admin_user: Final = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test" + ) + + response: Final[RouterSettingsResponse] = await get_router_settings( + user_api_key_dict=admin_user + ) + + assert response.current_values["routing_groups"] == expected + groups_field: Final = next( + field for field in response.fields if field.field_name == "routing_groups" + ) + assert groups_field.field_value == expected diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 8bf9c598b1a..7a9b6b66946 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -5129,6 +5129,168 @@ async def test_team_member_delete_cleans_verification_tokens( ) +@pytest.mark.asyncio +async def test_team_member_delete_writes_deleted_audit_log_for_member_keys( + mock_db_client, mock_admin_auth +): + from litellm.proxy._types import ( + LiteLLM_VerificationToken, + LitellmTableNames, + TeamMemberDeleteRequest, + ) + from litellm.proxy.management_endpoints.team_endpoints import team_member_delete + + test_team_id = "team-del-audit-123" + test_user_id = "user-audit@example.com" + member_key = LiteLLM_VerificationToken(token="hashed-member-key", team_id=test_team_id, user_id=test_user_id) + + mock_team_row = MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": test_team_id, + "members_with_roles": [ + {"user_id": test_user_id, "user_email": None, "role": "user"} + ], + "team_member_permissions": [], + "metadata": {}, + "models": [], + "spend": 0.0, + } + + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) + mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) + + mock_user_row = MagicMock() + mock_user_row.user_id = test_user_id + mock_user_row.teams = [test_team_id] + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[mock_user_row] + ) + mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) + + mock_db_client.db.litellm_teammembership = MagicMock() + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) + + mock_db_client.db.litellm_verificationtoken = MagicMock() + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[member_key]) + mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock( + return_value=MagicMock() + ) + mock_db_client.db.litellm_jwtkeymapping = MagicMock() + mock_db_client.db.litellm_jwtkeymapping.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_deletedverificationtoken = MagicMock() + mock_db_client.db.litellm_deletedverificationtoken.create_many = AsyncMock(return_value=MagicMock()) + + _wire_member_delete_tx(mock_db_client) + + captured: Final[list] = [] + + async def _capture(request_data): + captured.append(request_data) + + with ( + patch("litellm.store_audit_logs", True), + patch( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + new=_capture, + ), + ): + await team_member_delete( + data=TeamMemberDeleteRequest(team_id=test_team_id, user_id=test_user_id), + user_api_key_dict=mock_admin_auth, + ) + for _ in range(100): + if captured: + break + await asyncio.sleep(0.01) + + key_rows: Final = [r for r in captured if r.table_name == LitellmTableNames.KEY_TABLE_NAME] + assert len(key_rows) == 1 + audit_row: Final = key_rows[0] + assert audit_row.action == "deleted" + assert audit_row.object_id == member_key.token + assert audit_row.changed_by + assert json.loads(audit_row.before_value)["token"] == member_key.token + + +@pytest.mark.asyncio +async def test_delete_team_writes_deleted_audit_log_for_team_keys( + monkeypatch, +): + from litellm.proxy._types import DeleteTeamRequest, LiteLLM_VerificationToken, LitellmTableNames + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + + team = LiteLLM_TeamTable( + team_id="team-doomed", + team_alias="doomed-team", + members_with_roles=[], + metadata={}, + model_max_budget={}, + model_spend={}, + ) + team_key = LiteLLM_VerificationToken(token="hashed-doomed-key", team_id="team-doomed") + + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team) + mock_prisma_client.delete_data = AsyncMock(return_value={"deleted_teams": ["team-doomed"]}) + mock_prisma_client.db.litellm_deletedteamtable.create_many = AsyncMock() + mock_prisma_client.db.litellm_deletedverificationtoken.create_many = AsyncMock() + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[team_key]) + mock_prisma_client.db.execute_raw = AsyncMock() + mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock() + mock_prisma_client.get_data = AsyncMock(return_value=None) + + mock_tx = AsyncMock() + mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + mock_tx_cm = MagicMock() + mock_tx_cm.__aenter__ = AsyncMock(return_value=mock_tx) + mock_tx_cm.__aexit__ = AsyncMock(return_value=False) + mock_prisma_client.db.tx = MagicMock(return_value=mock_tx_cm) + _wire_team_delete_tx(mock_prisma_client) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", UserApiKeyCache()) + monkeypatch.setattr("litellm.proxy.proxy_server.create_audit_log_for_update", AsyncMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin") + + caller = UserAPIKeyAuth( + user_id="admin-user", + api_key="sk-admin", + user_role=LitellmUserRoles.PROXY_ADMIN.value, + ) + captured: Final[list] = [] + + async def _capture(request_data): + captured.append(request_data) + + monkeypatch.setattr("litellm.store_audit_logs", True) + monkeypatch.setattr( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + _capture, + ) + await delete_team( + data=DeleteTeamRequest(team_ids=["team-doomed"]), + http_request=MagicMock(), + user_api_key_dict=caller, + litellm_changed_by="admin-user", + ) + for _ in range(100): + if captured: + break + await asyncio.sleep(0.01) + + key_rows: Final = [r for r in captured if r.table_name == LitellmTableNames.KEY_TABLE_NAME] + assert len(key_rows) == 1 + audit_row: Final = key_rows[0] + assert audit_row.action == "deleted" + assert audit_row.object_id == team_key.token + assert audit_row.changed_by + assert json.loads(audit_row.before_value)["token"] == team_key.token + + @pytest.mark.asyncio async def test_team_member_delete_reads_on_the_lock_holding_transaction( mock_db_client, mock_admin_auth diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index 3c05068c4b0..32e5bea613c 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -1,3 +1,4 @@ +import asyncio import copy import json from collections.abc import Callable, Mapping, Sequence @@ -7,7 +8,7 @@ from typing import Final import pytest from pydantic import BaseModel, ConfigDict, ValidationError -from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth +from litellm.proxy._types import LiteLLM_TeamTable, LitellmTableNames, LitellmUserRoles, Member, UserAPIKeyAuth from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.list_api.common import ManagementProblem @@ -748,3 +749,96 @@ def test_request_models_reject_unknown_fields(): BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "role": "admin"}]}) with pytest.raises(ValidationError, match="dry_run"): BulkDeleteUserRequest.model_validate({"user_ids": ["u1"], "dry_run": True}) + + +@pytest.mark.asyncio +async def test_bulk_delete_writes_deleted_audit_log_for_deleted_keys(mocker): + prisma = _FakePrisma( + users=[_user("u1", "t1"), _user("keep", "t1")], + teams=[_team("t1", "u1", "keep")], + tokens=[ + {"token": "team-key", "user_id": "u1", "team_id": "t1"}, + {"token": "personal-key", "user_id": "u1"}, + {"token": "keep-key", "user_id": "keep", "team_id": "t1"}, + ], + ) + + captured: Final[list] = [] + + async def _capture(request_data): + captured.append(request_data) + + mocker.patch("litellm.store_audit_logs", True) + mocker.patch( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + new=_capture, + ) + await _delete(prisma, ["u1"]) + for _ in range(100): + if len([r for r in captured if r.table_name == LitellmTableNames.KEY_TABLE_NAME]) >= 2: + break + await asyncio.sleep(0.01) + + key_rows: Final = [r for r in captured if r.table_name == LitellmTableNames.KEY_TABLE_NAME] + assert {r.object_id for r in key_rows} == {"team-key", "personal-key"} + assert {r.action for r in key_rows} == {"deleted"} + assert all(r.changed_by for r in key_rows) + assert {json.loads(r.before_value)["token"] for r in key_rows} == {"team-key", "personal-key"} + + +@pytest.mark.asyncio +async def test_bulk_member_delete_writes_deleted_audit_log_for_removed_team_keys(mocker): + prisma = _FakePrisma( + users=[_user("u1", "t1"), _user("keep", "t1")], + teams=[_team("t1", "u1", "keep")], + tokens=[ + {"token": "team-key", "user_id": "u1", "team_id": "t1"}, + {"token": "keep-key", "user_id": "keep", "team_id": "t1"}, + ], + ) + + captured: Final[list] = [] + + async def _capture(request_data): + captured.append(request_data) + + mocker.patch("litellm.store_audit_logs", True) + mocker.patch( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + new=_capture, + ) + await _remove(prisma, "t1", [{"user_id": "u1"}]) + for _ in range(100): + if captured: + break + await asyncio.sleep(0.01) + + key_rows: Final = [r for r in captured if r.table_name == LitellmTableNames.KEY_TABLE_NAME] + assert len(key_rows) == 1 + audit_row: Final = key_rows[0] + assert audit_row.action == "deleted" + assert audit_row.object_id == "team-key" + assert audit_row.changed_by + assert json.loads(audit_row.before_value)["token"] == "team-key" + + +@pytest.mark.asyncio +async def test_bulk_delete_skips_the_key_audit_log_when_the_tx_rolls_back(mocker): + prisma = _FakePrisma( + users=[_user("u1", "a-good", "z-bad"), _user("u2", "a-good")], + teams=[_team("a-good", "u1", "u2"), _team("z-bad", "u1")], + tokens=[{"token": "k1", "user_id": "u1", "team_id": "a-good"}], + fail_locks=frozenset({"z-bad"}), + ) + cache = _cache_with("k1") + + mocker.patch("litellm.store_audit_logs", True) + mock_audit_write = mocker.patch( + "litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update", + new=mocker.AsyncMock(), + ) + results = await _delete(prisma, ["u1", "u2"], cache=cache) + + assert [(r.user_id, r.success) for r in results] == [("u1", False), ("u2", False)] + assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k1"] + mock_audit_write.assert_not_called() diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py index ba8b5fa3ac4..094320225cd 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py @@ -588,43 +588,6 @@ class TestAzureAnthropicCostCalculation: == "claude-3-5-haiku-20241022" ) - def test_passthrough_logging_sets_response_cost_with_server_tool_use_dict(self): - from litellm.types.utils import Choices, Message, ModelResponse - - logging_obj = self._create_mock_logging_obj(model="claude-3-7-sonnet-20250219") - logging_obj.get_router_model_id.return_value = None - logging_obj.litellm_params = {} - - response = ModelResponse( - id="test-id", - choices=[ - Choices( - finish_reason="stop", - index=0, - message=Message(content="test", role="assistant"), - ) - ], - created=1234567890, - model="claude-3-7-sonnet-20250219", - usage={ - "prompt_tokens": 10, - "completion_tokens": 5, - "total_tokens": 15, - "server_tool_use": {"web_search_requests": 1}, - }, - ) - - kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload( - litellm_model_response=response, - model="claude-3-7-sonnet-20250219", - kwargs={}, - start_time=datetime.now(), - end_time=datetime.now(), - logging_obj=logging_obj, - ) - - assert "response_cost" in kwargs - assert kwargs["response_cost"] > 0 class TestAnthropicBatchPassthroughCostTracking: @@ -2355,42 +2318,6 @@ class TestAnthropicResponseCostRecordedOnModelCallDetails: model_call_details["response_cost"], not from kwargs, so the streaming payload builder must record it there or streaming pass-through logs $0.""" - def test_create_payload_records_response_cost_on_model_call_details(self): - from litellm.types.utils import Choices, Message, ModelResponse - - logging_obj = MagicMock() - logging_obj.model_call_details = {} - logging_obj.get_router_model_id.return_value = None - logging_obj.litellm_params = {} - logging_obj.litellm_call_id = "test-call-id" - - response = ModelResponse( - id="test-id", - choices=[ - Choices( - finish_reason="stop", - index=0, - message=Message(content="hello", role="assistant"), - ) - ], - created=1234567890, - model="claude-3-7-sonnet-20250219", - usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - ) - - kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload( - litellm_model_response=response, - model="claude-3-7-sonnet-20250219", - kwargs={}, - start_time=datetime.now(), - end_time=datetime.now(), - logging_obj=logging_obj, - ) - - assert ( - logging_obj.model_call_details["response_cost"] == kwargs["response_cost"] - ) - assert logging_obj.model_call_details["response_cost"] > 0 class TestAnthropicPassthroughFastMode: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index fd81fcc8e72..353ffadfa46 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -7200,6 +7200,46 @@ class TestFalAIPassthroughRoute: assert "no pricing entry" in response.text assert not route.calls + def test_submit_to_catalog_key_the_pricer_cannot_price_returns_400_without_upstream_call( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/priceless-model", + {"litellm_provider": "fal_ai", "mode": "image_generation"}, + ) + with respx.mock(assert_all_called=False) as upstream: + route = upstream.post("https://queue.fal.run/fal-ai/priceless-model").mock( + return_value=httpx.Response(200, json={"request_id": "req-1"}) + ) + response = client.post("/fal_ai/fal-ai/priceless-model", json={"image_url": "https://example.com/in.png"}) + + assert response.status_code == 400, response.text + assert "no pricing entry" in response.text + assert not route.calls + + def test_submit_gate_prices_the_request_body_not_an_empty_one( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem( + litellm.model_cost, + "fal_ai/fal-ai/keyed-only-model", + {"litellm_provider": "fal_ai", "mode": "image_generation", "output_cost_per_image_512": 0.02}, + ) + with respx.mock(assert_all_called=False) as upstream: + route = upstream.post("https://queue.fal.run/fal-ai/keyed-only-model").mock( + return_value=httpx.Response(200, json={"request_id": "req-1"}) + ) + priced = client.post( + "/fal_ai/fal-ai/keyed-only-model", json={"image_url": "https://example.com/in.png", "resolution": "512"} + ) + unpriced = client.post("/fal_ai/fal-ai/keyed-only-model", json={"image_url": "https://example.com/in.png"}) + + assert priced.status_code == 200, priced.text + assert unpriced.status_code == 400, unpriced.text + assert "no pricing entry" in unpriced.text + assert len(route.calls) == 1 + def test_status_get_on_unpriced_endpoint_forwards(self, client: TestClient) -> None: with respx.mock(assert_all_called=True) as upstream: upstream.get("https://queue.fal.run/fal-ai/unpriced-model/requests/req-9/status").mock( diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py index 1d2d7d4d5c3..46f024366ec 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py @@ -462,30 +462,6 @@ class TestVertexAIBatchPassthroughHandler: assert mock_store.call_args[1]["unified_object_id"] assert mock_store.call_args[1]["is_batch_create"] is expected - def test_batch_cost_calculation_integration(self): - """Single Vertex AI response → non-zero cost with correct token counts.""" - from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - - vertex_ai_batch_responses = [ - { - "response": { - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - } - } - } - ] - - result = calculate_vertex_ai_batch_cost_and_usage( - vertex_ai_batch_responses, model_name="gemini-2.0-flash-001" - ) - - assert result.usage.total_tokens == 15 - assert result.usage.prompt_tokens == 10 - assert result.usage.completion_tokens == 5 - assert result.cost > 0, "batch_cost_calculator should return a non-zero cost" def test_batch_response_transformation(self): """Test transformation of Vertex AI batch responses to OpenAI format""" @@ -639,76 +615,7 @@ class TestVertexAIBatchCostCalculation: batch_cost_calculator — no VertexGeminiConfig transformation involved. """ - def test_should_aggregate_cost_and_usage_across_responses(self): - """Two successful responses → costs and token counts are summed.""" - from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - responses = [ - { - "response": { - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - } - } - }, - { - "response": { - "usageMetadata": { - "promptTokenCount": 8, - "candidatesTokenCount": 3, - "totalTokenCount": 11, - } - } - }, - ] - - result = calculate_vertex_ai_batch_cost_and_usage( - responses, model_name="gemini-2.0-flash-001" - ) - - assert result.usage.prompt_tokens == 18 - assert result.usage.completion_tokens == 8 - assert result.usage.total_tokens == 26 - assert result.cost > 0, "batch_cost_calculator should return a non-zero cost" - - def test_should_skip_responses_with_null_response_body(self): - """Failed lines (response: None) are skipped without error.""" - from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - - responses = [ - { - "response": { - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - } - } - }, - {"status": "JOB_STATE_FAILED", "response": None}, - { - "response": { - "usageMetadata": { - "promptTokenCount": 8, - "candidatesTokenCount": 3, - "totalTokenCount": 11, - } - } - }, - ] - - result = calculate_vertex_ai_batch_cost_and_usage( - responses, model_name="gemini-2.0-flash-001" - ) - - assert result.usage.prompt_tokens == 18 - assert result.usage.completion_tokens == 8 - assert result.usage.total_tokens == 26 - assert result.cost > 0 - assert result.successful_requests == 2 - assert result.failed_requests == 1 def test_should_return_zeros_for_empty_response_list(self): """Empty input → zero cost and zero usage.""" @@ -739,143 +646,4 @@ class TestVertexAIBatchCostCalculation: assert result.usage.completion_tokens == 0 assert result.usage.total_tokens == 0 - @pytest.mark.asyncio - async def test_openai_shaped_output_records_nonzero_cost_and_usage(self): - """ - Regression test for the bug where Vertex batch cost/usage was always 0. - After PR #25627 (transform_file_content_response), the GCS predictions.jsonl - is rewritten into OpenAI batch shape before the cost-tracking path sees it. - With disable_vertex_batch_output_transformation=False (default), the cost - dispatch must fall through to the generic aggregation path rather than - calling calculate_vertex_ai_batch_cost_and_usage (which only reads raw - usageMetadata fields). - """ - import litellm - from litellm.batches.batch_utils import calculate_batch_cost_and_usage - - openai_shaped_responses = [ - { - "id": "batch_req_abc123", - "custom_id": "request-1", - "response": { - "status_code": 200, - "request_id": "chatcmpl-xyz", - "body": { - "id": "chatcmpl-xyz", - "object": "chat.completion", - "model": "gemini-2.0-flash-001", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello!"}, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 10, - "completion_tokens": 5, - "total_tokens": 15, - }, - }, - }, - "error": None, - }, - { - "id": "batch_req_def456", - "custom_id": "request-2", - "response": { - "status_code": 200, - "request_id": "chatcmpl-uvw", - "body": { - "id": "chatcmpl-uvw", - "object": "chat.completion", - "model": "gemini-2.0-flash-001", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "World!"}, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 8, - "completion_tokens": 3, - "total_tokens": 11, - }, - }, - }, - "error": None, - }, - ] - - original_flag = getattr( - litellm, "disable_vertex_batch_output_transformation", False - ) - try: - litellm.disable_vertex_batch_output_transformation = False - - result = await calculate_batch_cost_and_usage( - file_content_dictionary=openai_shaped_responses, - custom_llm_provider="vertex_ai", - model_name="gemini-2.0-flash-001", - ) - finally: - litellm.disable_vertex_batch_output_transformation = original_flag - - assert ( - result.usage.prompt_tokens == 18 - ), f"expected 18 prompt tokens, got {result.usage.prompt_tokens}" - assert ( - result.usage.completion_tokens == 8 - ), f"expected 8 completion tokens, got {result.usage.completion_tokens}" - assert ( - result.usage.total_tokens == 26 - ), f"expected 26 total tokens, got {result.usage.total_tokens}" - assert ( - result.cost > 0 - ), f"expected non-zero cost for completed Vertex batch, got {result.cost}" - - @pytest.mark.asyncio - async def test_raw_vertex_output_still_works_when_transformation_disabled(self): - """ - When disable_vertex_batch_output_transformation=True the GCS file is returned - as raw Vertex predictions.jsonl; the specialized reader must be used. - """ - import litellm - from litellm.batches.batch_utils import calculate_batch_cost_and_usage - - raw_vertex_responses = [ - { - "request": {"contents": [{"role": "user", "parts": [{"text": "hi"}]}]}, - "status": "", - "response": { - "candidates": [{"content": {"parts": [{"text": "Hello!"}]}}], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - }, - }, - "processed_time": "2026-01-01T00:00:00Z", - }, - ] - - original_flag = getattr( - litellm, "disable_vertex_batch_output_transformation", False - ) - try: - litellm.disable_vertex_batch_output_transformation = True - - result = await calculate_batch_cost_and_usage( - file_content_dictionary=raw_vertex_responses, - custom_llm_provider="vertex_ai", - model_name="gemini-2.0-flash-001", - ) - finally: - litellm.disable_vertex_batch_output_transformation = original_flag - - assert result.usage.prompt_tokens == 10 - assert result.usage.completion_tokens == 5 - assert result.usage.total_tokens == 15 - assert result.cost > 0, "raw Vertex shape should also produce non-zero cost" diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index 462489f48b0..b47cce43dcc 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -32,6 +32,7 @@ from litellm.proxy.proxy_server import ( _scrub_guardrail_inner, resolve_complexity_router_plugins, resolve_routing_plugins, + validate_deployment_access_windows, validate_deployment_complexity_router_placement, validate_deployment_max_agentic_loops, validate_auto_router_capability_limits, @@ -2197,6 +2198,34 @@ async def test_ProxyConfig_load_config_wires_general_settings_url_validation(tmp litellm.provider_url_destination_allowed_hosts = original_provider_hosts +@pytest.mark.asyncio +async def test_ssrf_block_message_names_a_config_section_load_config_honors(tmp_path, monkeypatch): + """Regression for LIT-8349: the remediation in the SSRF block message must point at a section that works.""" + from litellm.litellm_core_utils.url_utils import SSRFError, validate_url + + monkeypatch.setattr(litellm, "user_url_allowed_hosts", []) + monkeypatch.setattr(litellm, "user_url_validation", True) + with pytest.raises(SSRFError) as blocked: + validate_url("http://10.96.3.245:10002/agent.json") + section_match = re.search(r"add the host to `user_url_allowed_hosts` in (\w+)\.", str(blocked.value)) + assert section_match is not None, str(blocked.value) + section: Final = section_match.group(1) + assert section == "litellm_settings", f"block message points admins at {section}, which the docs contradict" + + f = tmp_path / "c.yaml" + f.write_text(f"model_list: []\n{section}:\n user_url_allowed_hosts:\n - '10.96.3.245:10002'\n") + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) + monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) + await ProxyConfig().load_config(router=None, config_file_path=str(f)) + + assert litellm.user_url_allowed_hosts == ["10.96.3.245:10002"], f"{section} did not apply the allowlist" + assert validate_url("http://10.96.3.245:10002/agent.json") == ( + "http://10.96.3.245:10002/agent.json", + "10.96.3.245:10002", + ) + + @pytest.mark.asyncio async def test_ProxyConfig_load_config_wires_config_reload_interval(tmp_path, monkeypatch): """general_settings.proxy_config_reload_interval_seconds must reach the proxy_server @@ -4714,3 +4743,59 @@ def test_websearch_interception_settings_can_be_named_in_supported_db_objects(mo monkeypatch.setattr(proxy_server, "general_settings", {"supported_db_objects": ["models"]}) assert proxy_server.should_load_db_object(object_type="websearch_interception_settings") is False + + +def test_validate_deployment_access_windows_rejects_malformed_time(): + model = { + "model_name": "gpt-4o-shared", + "litellm_params": {"model": "gpt-4o"}, + "model_info": { + "access_windows": [ + {"start": "25:00", "end": "06:00", "timezone": "America/New_York", "team_ids": ["t"]} + ] + }, + } + + with pytest.raises(ValueError, match="access_windows") as exc_info: + validate_deployment_access_windows(model) + + assert "gpt-4o-shared" in str(exc_info.value) + + +def test_validate_deployment_access_windows_rejects_unknown_timezone(): + model = { + "model_name": "gpt-4o-shared", + "litellm_params": {"model": "gpt-4o"}, + "model_info": { + "access_windows": [ + {"start": "22:00", "end": "06:00", "timezone": "Mars/Olympus", "team_ids": ["t"]} + ] + }, + } + + with pytest.raises(ValueError, match="Mars/Olympus"): + validate_deployment_access_windows(model) + + +def test_validate_deployment_access_windows_accepts_valid_and_absent(): + assert ( + validate_deployment_access_windows( + { + "model_name": "gpt-4o-shared", + "litellm_params": {"model": "gpt-4o"}, + "model_info": { + "access_windows": [ + {"start": "22:00", "end": "06:00", "timezone": "America/New_York", "team_ids": ["t"]} + ] + }, + } + ) + is None + ) + assert validate_deployment_access_windows({"model_name": "m", "litellm_params": {"model": "m"}}) is None + assert ( + validate_deployment_access_windows( + {"model_name": "m", "litellm_params": {"model": "m"}, "model_info": {"id": "x"}} + ) + is None + ) diff --git a/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py b/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py index 13488106df4..9df8e6f4d67 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py +++ b/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py @@ -1,10 +1,8 @@ from __future__ import annotations -import json import math from datetime import datetime, timedelta, timezone -from types import MappingProxyType -from typing import Final, cast +from typing import Final import pytest @@ -24,16 +22,12 @@ from litellm.proxy.common_utils.user_api_key_cache import ( ) from litellm.proxy.spend_tracking.budget_reservation import ( _get_team_member_budget_counter, - count_request_input_tokens, estimate_request_max_cost, release_unbound_budget_reservation, reserve_budget_for_request, ) from litellm.proxy.utils import ProxyLogging from litellm.router import Router -from litellm.rust_bridge import bindings, configuration -from litellm.rust_bridge import token_counter as rust_token_counter -from litellm.rust_bridge import tokenizer as tokenizer_dispatch from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo TOKEN_COUNTING_ROUTES: Final = ( @@ -222,249 +216,6 @@ def test_deployment_pricing_update_invalidates_cached_estimate() -> None: assert math.isclose(after, before * 1000) -ANTHROPIC_TOKENIZER_MODEL: Final = "claude-sonnet-4-5-20250929" -CL100K_MODEL: Final = "gpt-4" -O200K_MODEL: Final = "gpt-4o" -RUST_COUNTED_BODY: Final = {"model": ANTHROPIC_TOKENIZER_MODEL, "max_tokens": 16, "messages": ANTHROPIC_MESSAGES} -RUST_INPUT_TOKENS: Final = 4_321 -RUST_INPUT_TOKENS_BY_TOKENIZER: Final = MappingProxyType( - {"anthropic": RUST_INPUT_TOKENS, "cl100k_base": 1_234, "o200k_base": 2_345} -) - - -class _FakeDeclined(Exception): - pass - - -class _FakeUpstream(Exception): - pass - - -class _FakeTokenizer: - """Stands in for one shared native `Tokenizer`; only its name identifies it.""" - - def __init__(self, name: str, json: str | None = None) -> None: - self.name = name - self.json = json - - -def _fake_native_tokenizers(monkeypatch: pytest.MonkeyPatch, anthropic_json: str | None = None) -> None: - """Point the counter's tokenizer lookups at fakes; the codec path keeps falling back to Python.""" - fakes: Final = {name: _FakeTokenizer(name) for name in ("cl100k_base", "o200k_base")} - anthropic: Final = _FakeTokenizer("anthropic", anthropic_json) - monkeypatch.setattr(tokenizer_dispatch, "native_encoding", fakes.__getitem__) - monkeypatch.setattr(tokenizer_dispatch, "native_anthropic", lambda: anthropic) - - -class _FakeNative: - RustBridgeDeclined = _FakeDeclined - RustUpstreamError = _FakeUpstream - - -class _RecordingCounter: - """Stands in for one native counter; records `(tokenizer, body)` on the shared factory.""" - - def __init__(self, factory: _RecordingFactory, tokenizer: rust_token_counter.RustTokenizer) -> None: - self.factory = factory - self.tokenizer = tokenizer - - async def acount_request(self, body: bytes) -> object: - self.factory.calls.append((self.tokenizer, body)) - return {"model": "", "input_tokens": RUST_INPUT_TOKENS_BY_TOKENIZER[self.tokenizer]} - - -class _RecordingFactory: - """Stands in for the native `TokenCounter` class, built over a loaded `Tokenizer`.""" - - def __init__(self) -> None: - self.calls: list[tuple[rust_token_counter.RustTokenizer, bytes]] = [] - - def from_tokenizer(self, tokenizer: _FakeTokenizer, fast: bool = False) -> _RecordingCounter: - return _RecordingCounter(self, cast(rust_token_counter.RustTokenizer, tokenizer.name)) - - -class _DecliningCounter: - async def acount_request(self, body: bytes) -> object: - raise _FakeDeclined("unsupported content block") - - -class _DecliningFactory: - def from_tokenizer(self, tokenizer: _FakeTokenizer, fast: bool = False) -> _DecliningCounter: - return _DecliningCounter() - - -@pytest.fixture -def rust_counter(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(bindings, "get_native_bridge", lambda: _FakeNative()) - _fake_native_tokenizers(monkeypatch) - rust_token_counter._counter.cache_clear() - configuration.reset_rust_configuration() - yield - rust_token_counter.TOKEN_COUNTER.reset() - rust_token_counter._counter.cache_clear() - configuration.reset_rust_configuration() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("route", "request_body"), - ( - ("/v1/messages", RUST_COUNTED_BODY), - ("/v1/chat/completions", {"model": ANTHROPIC_TOKENIZER_MODEL, "messages": ANTHROPIC_MESSAGES}), - ("/v1/completions", {"model": ANTHROPIC_TOKENIZER_MODEL, "prompt": "hi"}), - ("/v1/responses", {"model": ANTHROPIC_TOKENIZER_MODEL, "input": "hi"}), - ("/v1/embeddings", {"model": ANTHROPIC_TOKENIZER_MODEL, "input": ["hi"]}), - ("/v1/rerank", {"model": ANTHROPIC_TOKENIZER_MODEL, "query": "hi", "documents": ["a"]}), - ), -) -async def test_rust_count_replaces_python_tokenizing_on_every_llm_route( - rust_counter: None, route: str, request_body: dict -) -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - rust_token_counter.TOKEN_COUNTER.override(factory) - raw_body: Final = json.dumps(request_body).encode() - - counts: Final = await count_request_input_tokens( - request_body=request_body, route=route, llm_router=None, raw_body=raw_body - ) - - assert dict(counts) == {ANTHROPIC_TOKENIZER_MODEL: RUST_INPUT_TOKENS} - assert factory.calls == [("anthropic", raw_body)] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", (CL100K_MODEL, "azure/gpt-35-turbo", "gemini/gemini-2.5-pro", "my-router-alias")) -async def test_tiktoken_cl100k_models_are_counted_by_rust(rust_counter: None, model: str) -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - rust_token_counter.TOKEN_COUNTER.override(factory) - body: Final = {"model": model, "messages": ANTHROPIC_MESSAGES} - raw_body: Final = json.dumps(body).encode() - - counts: Final = await count_request_input_tokens( - request_body=body, route="/v1/chat/completions", llm_router=None, raw_body=raw_body - ) - - assert dict(counts) == {model: RUST_INPUT_TOKENS_BY_TOKENIZER["cl100k_base"]} - assert factory.calls == [("cl100k_base", raw_body)] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", (O200K_MODEL, "gpt-5", "o3", "gpt-4.1", "chatgpt-4o-latest")) -async def test_tiktoken_o200k_models_are_counted_by_rust(rust_counter: None, model: str) -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - rust_token_counter.TOKEN_COUNTER.override(factory) - body: Final = {"model": model, "messages": ANTHROPIC_MESSAGES} - raw_body: Final = json.dumps(body).encode() - - counts: Final = await count_request_input_tokens( - request_body=body, route="/v1/chat/completions", llm_router=None, raw_body=raw_body - ) - - assert dict(counts) == {model: RUST_INPUT_TOKENS_BY_TOKENIZER["o200k_base"]} - assert factory.calls == [("o200k_base", raw_body)] - - -@pytest.mark.asyncio -async def test_multi_model_request_counts_once_per_tokenizer_and_python_for_the_rest(rust_counter: None) -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - rust_token_counter.TOKEN_COUNTER.override(factory) - models: Final = ( - CL100K_MODEL, - ANTHROPIC_TOKENIZER_MODEL, - "gemini/gemini-2.5-pro", - O200K_MODEL, - "gpt-5", - "replicate/meta/llama-2-70b-chat", - ) - body: Final = {"model": list(models), "messages": ANTHROPIC_MESSAGES} - raw_body: Final = json.dumps(body).encode() - python_counts: Final = await count_request_input_tokens( - request_body=body, route="/v1/chat/completions", llm_router=None - ) - - counts: Final = await count_request_input_tokens( - request_body=body, route="/v1/chat/completions", llm_router=None, raw_body=raw_body - ) - - assert factory.calls == [("cl100k_base", raw_body), ("anthropic", raw_body), ("o200k_base", raw_body)] - assert dict(counts) == { - CL100K_MODEL: RUST_INPUT_TOKENS_BY_TOKENIZER["cl100k_base"], - "gemini/gemini-2.5-pro": RUST_INPUT_TOKENS_BY_TOKENIZER["cl100k_base"], - ANTHROPIC_TOKENIZER_MODEL: RUST_INPUT_TOKENS, - O200K_MODEL: RUST_INPUT_TOKENS_BY_TOKENIZER["o200k_base"], - "gpt-5": RUST_INPUT_TOKENS_BY_TOKENIZER["o200k_base"], - "replicate/meta/llama-2-70b-chat": python_counts["replicate/meta/llama-2-70b-chat"], - } - assert counts["replicate/meta/llama-2-70b-chat"] not in RUST_INPUT_TOKENS_BY_TOKENIZER.values() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", (ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL, O200K_MODEL)) -async def test_rust_decline_falls_back_to_python_count(rust_counter: None, model: str) -> None: - litellm.rust(True) - rust_token_counter.TOKEN_COUNTER.override(_DecliningFactory()) - body: Final = {**RUST_COUNTED_BODY, "model": model} - python_counts: Final = await count_request_input_tokens(request_body=body, route="/v1/messages", llm_router=None) - - counts: Final = await count_request_input_tokens( - request_body=body, - route="/v1/messages", - llm_router=None, - raw_body=json.dumps(body).encode(), - ) - - assert dict(counts) == dict(python_counts) - assert counts[model] not in RUST_INPUT_TOKENS_BY_TOKENIZER.values() - - -@pytest.mark.asyncio -async def test_disabled_rust_never_sees_the_raw_body(rust_counter: None) -> None: - factory: Final = _RecordingFactory() - litellm.rust(False) - rust_token_counter.TOKEN_COUNTER.override(factory) - body: Final = {"model": [ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL, O200K_MODEL], "messages": ANTHROPIC_MESSAGES} - - counts: Final = await count_request_input_tokens( - request_body=body, - route="/v1/chat/completions", - llm_router=None, - raw_body=json.dumps(body).encode(), - ) - - assert factory.calls == [] - assert set(counts) == {ANTHROPIC_TOKENIZER_MODEL, CL100K_MODEL, O200K_MODEL} - assert not set(counts.values()) & set(RUST_INPUT_TOKENS_BY_TOKENIZER.values()) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model", ("replicate/meta/llama-2-70b-chat", "meta-llama/Llama-3-8b", "text-davinci-003")) -async def test_models_without_a_rust_tokenizer_stay_in_python( - rust_counter: None, monkeypatch: pytest.MonkeyPatch, model: str -) -> None: - monkeypatch.setattr( - litellm, "open_ai_chat_completion_models", litellm.open_ai_chat_completion_models | {"text-davinci-003"} - ) - factory: Final = _RecordingFactory() - litellm.rust(True) - rust_token_counter.TOKEN_COUNTER.override(factory) - body: Final = {"model": model, "messages": ANTHROPIC_MESSAGES} - python_counts: Final = await count_request_input_tokens( - request_body=body, route="/v1/chat/completions", llm_router=None - ) - - counts: Final = await count_request_input_tokens( - request_body=body, route="/v1/chat/completions", llm_router=None, raw_body=json.dumps(body).encode() - ) - - assert factory.calls == [] - assert dict(counts) == dict(python_counts) - assert counts[model] not in RUST_INPUT_TOKENS_BY_TOKENIZER.values() - - @pytest.mark.asyncio @pytest.mark.parametrize( "expiry_offset, expected_max_budget", diff --git a/tests/test_litellm/proxy/spend_tracking/test_input_tokens.py b/tests/test_litellm/proxy/spend_tracking/test_input_tokens.py index 49bbe148386..a1da6c79cd7 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_input_tokens.py +++ b/tests/test_litellm/proxy/spend_tracking/test_input_tokens.py @@ -2,180 +2,18 @@ from __future__ import annotations -import json from types import MappingProxyType -from typing import Final, cast +from typing import Final import pytest -import litellm from litellm.proxy.spend_tracking.input_tokens import ( TOKENIZE_OFF_EVENT_LOOP_MIN_CHARS, count_input_tokens, count_input_tokens_for_model, ) -from litellm.rust_bridge import bindings, configuration, token_counter -from litellm.rust_bridge import tokenizer as tokenizer_dispatch -from litellm.rust_bridge.token_counter import RustTokenizer -ANTHROPIC_MODEL: Final = "claude-sonnet-4-5-20250929" CL100K_MODEL: Final = "gpt-4" -O200K_MODEL: Final = "gpt-4o" -PYTHON_ONLY_MODEL: Final = "replicate/meta/llama-2-70b-chat" -MESSAGES: Final = [{"role": "user", "content": "hello"}] -RUST_TOKENS: Final = 777 - - -class _FakeDeclined(Exception): - pass - - -class _FakeUpstream(Exception): - pass - - -class _FakeTokenizer: - """Stands in for one shared native `Tokenizer`; only its name identifies it.""" - - def __init__(self, name: str, json: str | None = None) -> None: - self.name = name - self.json = json - - -def _fake_native_tokenizers(monkeypatch: pytest.MonkeyPatch, anthropic_json: str | None = None) -> None: - """Point the counter's tokenizer lookups at fakes; the codec path keeps falling back to Python.""" - fakes: Final = {name: _FakeTokenizer(name) for name in ("cl100k_base", "o200k_base")} - anthropic: Final = _FakeTokenizer("anthropic", anthropic_json) - monkeypatch.setattr(tokenizer_dispatch, "native_encoding", fakes.__getitem__) - monkeypatch.setattr(tokenizer_dispatch, "native_anthropic", lambda: anthropic) - - -class _FakeNative: - RustBridgeDeclined = _FakeDeclined - RustUpstreamError = _FakeUpstream - - -class _RecordingCounter: - def __init__(self, factory: _RecordingFactory, tokenizer: RustTokenizer) -> None: - self.factory = factory - self.tokenizer = tokenizer - - async def acount_request(self, body: bytes) -> object: - self.factory.calls.append((self.tokenizer, body)) - return {"model": "", "input_tokens": RUST_TOKENS} - - -class _RecordingFactory: - def __init__(self) -> None: - self.calls: list[tuple[RustTokenizer, bytes]] = [] - - def from_tokenizer(self, tokenizer: _FakeTokenizer, fast: bool = False) -> _RecordingCounter: - return _RecordingCounter(self, cast(RustTokenizer, tokenizer.name)) - - -class _DecliningCounter: - async def acount_request(self, body: bytes) -> object: - raise _FakeDeclined("unsupported request shape") - - -class _DecliningFactory: - def from_tokenizer(self, tokenizer: _FakeTokenizer, fast: bool = False) -> _DecliningCounter: - return _DecliningCounter() - - -@pytest.fixture(autouse=True) -def _reset_bridge(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(bindings, "get_native_bridge", lambda: _FakeNative()) - _fake_native_tokenizers(monkeypatch) - token_counter.TOKEN_COUNTER.reset() - token_counter._counter.cache_clear() - configuration.reset_rust_configuration() - yield - token_counter.TOKEN_COUNTER.reset() - token_counter._counter.cache_clear() - configuration.reset_rust_configuration() - - -def _body(model: object) -> tuple[dict[str, object], bytes]: - body: Final = {"model": model, "messages": MESSAGES} - return body, json.dumps(body).encode() - - -@pytest.mark.asyncio -async def test_models_sharing_a_tokenizer_are_counted_once_and_merged() -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - token_counter.TOKEN_COUNTER.override(factory) - request_body, raw_body = _body([ANTHROPIC_MODEL, CL100K_MODEL, O200K_MODEL, "gpt-5", PYTHON_ONLY_MODEL]) - - counts: Final = await count_input_tokens( - request_body=request_body, - raw_body=raw_body, - models=(ANTHROPIC_MODEL, CL100K_MODEL, O200K_MODEL, "gpt-5", PYTHON_ONLY_MODEL), - ) - - assert factory.calls == [("anthropic", raw_body), ("cl100k_base", raw_body), ("o200k_base", raw_body)] - assert dict(counts) == { - ANTHROPIC_MODEL: RUST_TOKENS, - CL100K_MODEL: RUST_TOKENS, - O200K_MODEL: RUST_TOKENS, - "gpt-5": RUST_TOKENS, - PYTHON_ONLY_MODEL: count_input_tokens_for_model(request_body=request_body, model=PYTHON_ONLY_MODEL), - } - - -@pytest.mark.asyncio -async def test_rust_disabled_counts_everything_in_python() -> None: - factory: Final = _RecordingFactory() - litellm.rust(False) - token_counter.TOKEN_COUNTER.override(factory) - request_body, raw_body = _body([ANTHROPIC_MODEL, CL100K_MODEL]) - - counts: Final = await count_input_tokens( - request_body=request_body, raw_body=raw_body, models=(ANTHROPIC_MODEL, CL100K_MODEL) - ) - - assert factory.calls == [] - assert dict(counts) == { - model: count_input_tokens_for_model(request_body=request_body, model=model) - for model in (ANTHROPIC_MODEL, CL100K_MODEL) - } - - -@pytest.mark.asyncio -async def test_missing_raw_body_counts_in_python() -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - token_counter.TOKEN_COUNTER.override(factory) - request_body, _ = _body(ANTHROPIC_MODEL) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=None, models=(ANTHROPIC_MODEL,)) - - assert factory.calls == [] - assert counts[ANTHROPIC_MODEL] == count_input_tokens_for_model(request_body=request_body, model=ANTHROPIC_MODEL) - - -@pytest.mark.asyncio -async def test_missing_binding_counts_in_python() -> None: - litellm.rust(True) - token_counter.TOKEN_COUNTER.override(None) - request_body, raw_body = _body(ANTHROPIC_MODEL) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=raw_body, models=(ANTHROPIC_MODEL,)) - - assert counts[ANTHROPIC_MODEL] == count_input_tokens_for_model(request_body=request_body, model=ANTHROPIC_MODEL) - - -@pytest.mark.asyncio -async def test_declined_request_counts_in_python() -> None: - litellm.rust(True) - token_counter.TOKEN_COUNTER.override(_DecliningFactory()) - request_body, raw_body = _body(ANTHROPIC_MODEL) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=raw_body, models=(ANTHROPIC_MODEL,)) - - assert counts[ANTHROPIC_MODEL] == count_input_tokens_for_model(request_body=request_body, model=ANTHROPIC_MODEL) - assert counts[ANTHROPIC_MODEL] != RUST_TOKENS @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/spend_tracking/test_savings.py b/tests/test_litellm/proxy/spend_tracking/test_savings.py index 004f07da431..de413a86521 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_savings.py +++ b/tests/test_litellm/proxy/spend_tracking/test_savings.py @@ -634,25 +634,6 @@ def test_equal_modeled_usage_is_zero_under_equivalent_model_names() -> None: assert _savings("claude-opus-5", "anthropic/claude-opus-5", usage, usage) == 0.0 -def test_baseline_is_priced_under_its_own_provider(): - """Two providers can serve the same bare model name at different rates, so dropping - the provider prices the baseline against a vendor the operator never named. Here it - decides whether routing reads as a saving or a loss.""" - usage = Usage(prompt_tokens=100_000, completion_tokens=10_000, total_tokens=110_000) - azure = compute_autorouter_savings( - baseline_model="azure_ai/deepseek-r1", - selected_model="claude-haiku-4-5", - selected_provider="anthropic", - usage=usage, - ) - deepseek = compute_autorouter_savings( - baseline_model="deepseek/deepseek-r1", - selected_model="claude-haiku-4-5", - selected_provider="anthropic", - usage=usage, - ) - assert azure != pytest.approx(deepseek) - assert azure > 0 > deepseek def test_unresolvable_baseline_remains_unknown(): diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 9de6679472e..e41c027d962 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -139,6 +139,15 @@ def _reconstruct_ui_where_from_sql(sql_query, params): where["cache_hit"] = "hit" elif cond == "(cache_hit IS NULL OR LOWER(cache_hit) != 'true')": where["cache_hit"] = "miss" + elif "call_type" in cond: + if "call_type NOT IN" in cond: + where["span_type"] = "llm" + elif "call_mcp_tool" in cond: + where["span_type"] = "mcp" + elif "call_type = 'asend_message'" in cond: + where["span_type"] = "agent" + elif "acreate_batch" in cond: + where["span_type"] = "batch" elif sess: where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} elif status: @@ -3418,6 +3427,95 @@ async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_span_type_filter(client, monkeypatch): + base = { + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "status": "success", + } + mock_spend_logs = [ + {**base, "id": "log1", "request_id": "req-llm", "call_type": "acompletion"}, + {**base, "id": "log2", "request_id": "req-agent", "call_type": "asend_message"}, + {**base, "id": "log3", "request_id": "req-mcp", "call_type": "call_mcp_tool"}, + {**base, "id": "log4", "request_id": "req-batch", "call_type": "aretrieve_batch"}, + ] + + call_types_by_span = { + "llm": lambda ct: ct not in {"call_mcp_tool", "list_mcp_tools", "asend_message"} + and ct not in {"acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"}, + "agent": lambda ct: ct == "asend_message", + "mcp": lambda ct: ct in {"call_mcp_tool", "list_mcp_tools"}, + "batch": lambda ct: ct + in {"acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"}, + } + + def filter_by_span_type(where): + span_type = where.get("span_type") + if span_type is None: + return mock_spend_logs + return [log for log in mock_spend_logs if call_types_by_span[span_type](log["call_type"])] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_span_type), + ) + + start_date, end_date = _default_date_range() + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + for span_type, expected_ids in [ + ("batch", ["req-batch"]), + ("llm", ["req-llm"]), + ("mcp", ["req-mcp"]), + ("agent", ["req-agent"]), + ]: + response = client.get( + "/spend/logs/ui", + params={ + "span_type": span_type, + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["total"] == len(expected_ids) + assert [row["request_id"] for row in data["data"]] == expected_ids + + response = client.get( + "/spend/logs/ui", + params={ + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + assert response.json()["total"] == 4 + + response = client.get( + "/spend/logs/ui", + params={ + "span_type": "invalid", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 400 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_model(client, monkeypatch): mock_spend_logs = [ @@ -3789,179 +3887,7 @@ class TestSpendLogsPayload: } return mock_response - @pytest.mark.asyncio - async def test_spend_logs_payload_success_log_with_api_base(self, monkeypatch): - from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler - # Clear any env overrides that would change the recorded api_base - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) - monkeypatch.delenv("ANTHROPIC_API_BASE", raising=False) - - litellm.callbacks = [_ProxyDBLogger(message_logging=False)] - # litellm._turn_on_debug() - - client = AsyncHTTPHandler() - - with ( - patch.object( - litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter, - "_insert_spend_log_to_db", - ) as mock_client, - patch.object(litellm.proxy.proxy_server, "prisma_client"), - patch.object(client, "post", side_effect=self.mock_anthropic_response), - ): - response = await litellm.acompletion( - model="claude-4-sonnet-20250514", - messages=[{"role": "user", "content": "Hello, world!"}], - metadata={"user_api_key_end_user_id": "test_user_1"}, - client=client, - ) - - assert response.choices[0].message.content == "Hi! My name is Claude." - - await _wait_for_mock_call(mock_client) - - kwargs = mock_client.call_args.kwargs - payload: SpendLogsPayload = kwargs["payload"] - expected_payload = SpendLogsPayload( - **{ - "request_id": "chatcmpl-34df56d5-4807-45c1-bb99-61e52586b802", - "call_type": "acompletion", - "api_key": "", - "cache_hit": "None", - "startTime": datetime.datetime( - 2025, 3, 24, 22, 2, 42, 975883, tzinfo=datetime.timezone.utc - ), - "endTime": datetime.datetime( - 2025, 3, 24, 22, 2, 42, 989132, tzinfo=datetime.timezone.utc - ), - "completionStartTime": datetime.datetime( - 2025, 3, 24, 22, 2, 42, 989132, tzinfo=datetime.timezone.utc - ), - "model": "claude-4-sonnet-20250514", - "user": "", - "team_id": "", - "metadata": '{"applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', - "cache_key": "Cache OFF", - "spend": 0.01383, - "total_tokens": 2598, - "prompt_tokens": 2095, - "completion_tokens": 503, - "request_tags": "[]", - "end_user": "test_user_1", - "api_base": "https://api.anthropic.com/v1/messages", - "model_group": "", - "model_id": "", - "requester_ip_address": None, - "custom_llm_provider": "anthropic", - "messages": "{}", - "response": "{}", - "proxy_server_request": "{}", - "status": "success", - "mcp_namespaced_tool_name": None, - "agent_id": None, - } - ) - - differences = _compare_nested_dicts( - payload, expected_payload, ignore_keys=ignored_keys - ) - if differences: - pytest.fail(f"Dictionary mismatch: {differences}") - - @pytest.mark.asyncio - async def test_spend_logs_payload_success_log_with_router(self, monkeypatch): - from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler - - # Clear any env overrides that would change the recorded api_base - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) - monkeypatch.delenv("ANTHROPIC_API_BASE", raising=False) - - litellm.callbacks = [_ProxyDBLogger(message_logging=False)] - # litellm._turn_on_debug() - - client = AsyncHTTPHandler() - - router = Router( - model_list=[ - { - "model_name": "my-anthropic-model-group", - "litellm_params": { - "model": "claude-4-sonnet-20250514", - }, - "model_info": { - "id": "my-unique-model-id", - }, - } - ] - ) - - with ( - patch.object( - litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter, - "_insert_spend_log_to_db", - ) as mock_client, - patch.object(litellm.proxy.proxy_server, "prisma_client"), - patch.object(client, "post", side_effect=self.mock_anthropic_response), - ): - response = await router.acompletion( - model="my-anthropic-model-group", - messages=[{"role": "user", "content": "Hello, world!"}], - metadata={"user_api_key_end_user_id": "test_user_1"}, - client=client, - ) - - assert response.choices[0].message.content == "Hi! My name is Claude." - - await _wait_for_mock_call(mock_client) - - kwargs = mock_client.call_args.kwargs - payload: SpendLogsPayload = kwargs["payload"] - expected_payload = SpendLogsPayload( - **{ - "request_id": "chatcmpl-34df56d5-4807-45c1-bb99-61e52586b802", - "call_type": "acompletion", - "api_key": "", - "cache_hit": "None", - "startTime": datetime.datetime( - 2025, 3, 24, 22, 2, 42, 975883, tzinfo=datetime.timezone.utc - ), - "endTime": datetime.datetime( - 2025, 3, 24, 22, 2, 42, 989132, tzinfo=datetime.timezone.utc - ), - "completionStartTime": datetime.datetime( - 2025, 3, 24, 22, 2, 42, 989132, tzinfo=datetime.timezone.utc - ), - "model": "claude-4-sonnet-20250514", - "user": "", - "team_id": "", - "metadata": '{"applied_guardrails": [], "attempted_fallbacks": 0, "original_model_group": "my-anthropic-model-group", "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', - "cache_key": "Cache OFF", - "spend": 0.01383, - "total_tokens": 2598, - "prompt_tokens": 2095, - "completion_tokens": 503, - "request_tags": "[]", - "end_user": "test_user_1", - "api_base": "https://api.anthropic.com/v1/messages", - "model_group": "my-anthropic-model-group", - "model_id": "my-unique-model-id", - "requester_ip_address": None, - "custom_llm_provider": "anthropic", - "messages": "{}", - "response": "{}", - "proxy_server_request": "{}", - "status": "success", - "mcp_namespaced_tool_name": None, - "agent_id": None, - } - ) - - differences = _compare_nested_dicts( - payload, expected_payload, ignore_keys=ignored_keys - ) - if differences: - pytest.fail(f"Dictionary mismatch: {differences}") def _compare_nested_dicts( diff --git a/tests/test_litellm/proxy/test_bug_report_config.py b/tests/test_litellm/proxy/test_bug_report_config.py index 06bca8c66fb..6cffa55781e 100644 --- a/tests/test_litellm/proxy/test_bug_report_config.py +++ b/tests/test_litellm/proxy/test_bug_report_config.py @@ -5,7 +5,7 @@ from collections.abc import Iterator, Mapping import pytest from litellm.proxy import proxy_server -from litellm.proxy.bug_report_config import build_proxy_bug_report, safe_config_lines +from litellm.proxy.bug_report_config import build_proxy_bug_report, build_proxy_environment_report, safe_config_lines CUSTOMER_STRINGS = ( "acme", @@ -193,6 +193,14 @@ def loaded_proxy_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: def test_build_proxy_bug_report_reads_the_loaded_proxy_config(): report = build_proxy_bug_report(RuntimeError("boom"), stream=False) - assert report.surface == "proxy" + assert report.environment.surface == "proxy" assert report.stream is False - assert report.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) + assert report.environment.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) + + +@pytest.mark.usefixtures("loaded_proxy_config") +def test_proxy_environment_report_matches_the_bug_report_environment(): + environment = build_proxy_environment_report() + + assert environment == build_proxy_bug_report(RuntimeError("boom")).environment + assert environment.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 360122cde25..5b9cd761dda 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -35,10 +35,12 @@ from litellm.proxy.common_request_processing import ( _buffer_first_chunk_honoring_disconnect, _cancel_llm_call_on_client_disconnect, _ClientDisconnectedBeforeFirstChunk, + attach_guardrail_information, _extract_error_from_sse_chunk, _get_cost_breakdown_from_logging_obj, CostBreakdownHeaderValues, _has_attribute_error_in_chain, + include_guardrail_response_requested, _is_azure_model_router_request, open_sse_before_first_byte, resolve_litellm_call_id, @@ -61,6 +63,132 @@ from litellm.proxy.utils import ProxyLogging from litellm.router import Router +def test_attach_guardrail_information_copies_recorded_entries_onto_model_response(): + recorded = [ + {"guardrail_name": "first", "guardrail_status": "success"}, + {"guardrail_name": "second", "guardrail_status": "success"}, + ] + response = litellm.ModelResponse() + + result = attach_guardrail_information( + response=response, + request_data={"metadata": {"standard_logging_guardrail_information": recorded}}, + ) + + assert isinstance(result, litellm.ModelResponse) + assert result.model_dump()["guardrail_information"] == recorded + assert "guardrail_information" not in response.model_dump() + + +def test_attach_guardrail_information_reports_empty_list_when_nothing_ran(): + response = litellm.ModelResponse() + + result = attach_guardrail_information(response=response, request_data={}) + + assert isinstance(result, litellm.ModelResponse) + assert result.model_dump()["guardrail_information"] == [] + assert "guardrail_information" not in response.model_dump() + + +def test_attach_guardrail_information_sets_key_on_dict_response(): + recorded = [{"guardrail_name": "first", "guardrail_status": "success"}] + response = {"id": "x"} + + result = attach_guardrail_information( + response=response, + request_data={"metadata": {"standard_logging_guardrail_information": recorded}}, + ) + + assert isinstance(result, dict) + assert result == {"id": "x", "guardrail_information": recorded} + assert response == {"id": "x"} + + +def test_attach_guardrail_information_redacts_matched_content(): + recorded = [ + { + "guardrail_name": "cf", + "guardrail_status": "success", + "guardrail_response": [ + {"type": "blocked_word", "keyword": "secret-word", "action": "MASK"} + ], + "match_details": [{"snippet": "secret-word", "detection_method": "keyword"}], + } + ] + + result = attach_guardrail_information( + response={"id": "x"}, + request_data={"metadata": {"standard_logging_guardrail_information": recorded}}, + ) + + assert isinstance(result, dict) + guardrail_information = result["guardrail_information"] + assert isinstance(guardrail_information, list) + assert guardrail_information[0]["guardrail_response"][0]["keyword"] == "[REDACTED]" + assert guardrail_information[0]["match_details"][0]["snippet"] == "[REDACTED]" + assert guardrail_information[0]["match_details"][0]["detection_method"] == "keyword" + assert "secret-word" not in json.dumps(result) + + +def test_attach_guardrail_information_leaves_cached_dict_response_untouched(): + recorded = [{"guardrail_name": "cf", "guardrail_status": "success"}] + cached = {"id": "x", "content": []} + + result = attach_guardrail_information( + response=cached, + request_data={ + "metadata": { + "include_guardrail_response": True, + "standard_logging_guardrail_information": recorded, + } + }, + ) + + assert "guardrail_information" not in cached + assert result is not cached + assert isinstance(result, dict) + assert result["guardrail_information"] == recorded + + original = litellm.ModelResponse() + copied = attach_guardrail_information( + response=original, + request_data={"metadata": {"standard_logging_guardrail_information": recorded}}, + ) + + assert "guardrail_information" not in original.model_dump() + assert isinstance(copied, litellm.ModelResponse) + assert copied.model_dump()["guardrail_information"] == recorded + + +def test_include_guardrail_response_requested_reads_flag_from_metadata_when_router_seeded_litellm_metadata(): + recorded = [ + {"guardrail_name": "first", "guardrail_status": "success"}, + {"guardrail_name": "second", "guardrail_status": "success"}, + ] + request_data = { + "metadata": { + "include_guardrail_response": True, + "standard_logging_guardrail_information": recorded, + }, + "litellm_metadata": {}, + } + + assert include_guardrail_response_requested(request_data) is True + + response = litellm.ModelResponse() + result = attach_guardrail_information(response=response, request_data=request_data) + + assert isinstance(result, litellm.ModelResponse) + assert result.model_dump()["guardrail_information"] == recorded + + +def test_include_guardrail_response_requested_is_false_without_exact_true(): + assert include_guardrail_response_requested( + {"metadata": {"include_guardrail_response": "true"}, "litellm_metadata": {}} + ) is False + assert include_guardrail_response_requested({}) is False + + class TestProxyBaseLLMRequestProcessing: @pytest.mark.asyncio async def test_base_passthrough_process_llm_request_preserves_litellm_headers_for_non_streaming_response( diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 9257a2dd23d..b7b7b5d1942 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -34,6 +34,7 @@ from litellm.proxy.litellm_pre_call_utils import ( add_provider_specific_headers_to_request, check_if_token_is_service_account, clean_headers, + move_guardrails_to_metadata, ) from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY @@ -5187,6 +5188,45 @@ def test_clean_headers_strips_x_api_key_when_byok_enabled_but_x_api_key_was_auth # --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_move_guardrails_to_metadata_moves_include_guardrail_response_before_the_no_guardrail_early_out(): + policy_registry = MagicMock() + policy_registry.is_initialized.return_value = False + user_api_key_dict = UserAPIKeyAuth(api_key="test-key") + + true_data = { + "model": "gpt-4.1-mini", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": {}, + "include_guardrail_response": True, + } + with patch("litellm.proxy.policy_engine.policy_registry.get_policy_registry", return_value=policy_registry): + await move_guardrails_to_metadata( + data=true_data, + _metadata_variable_name="metadata", + user_api_key_dict=user_api_key_dict, + ) + + assert "include_guardrail_response" not in true_data + assert true_data["metadata"]["include_guardrail_response"] is True + + string_data = { + "model": "gpt-4.1-mini", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": {}, + "include_guardrail_response": "true", + } + with patch("litellm.proxy.policy_engine.policy_registry.get_policy_registry", return_value=policy_registry): + await move_guardrails_to_metadata( + data=string_data, + _metadata_variable_name="metadata", + user_api_key_dict=user_api_key_dict, + ) + + assert "include_guardrail_response" not in string_data + assert string_data["metadata"]["include_guardrail_response"] is False + + @pytest.mark.asyncio async def test_team_guardrail_merges_with_global_policy(): """ diff --git a/tests/test_litellm/proxy/test_native_compaction.py b/tests/test_litellm/proxy/test_native_compaction.py index d24624aacc5..778c1715530 100644 --- a/tests/test_litellm/proxy/test_native_compaction.py +++ b/tests/test_litellm/proxy/test_native_compaction.py @@ -7,6 +7,7 @@ import pytest from fastapi import FastAPI, Request from pydantic import TypeAdapter +import litellm from litellm.caching.caching import DualCache from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.initialize_dynamic_callback_params import inherit_message_logging_privacy @@ -112,8 +113,10 @@ async def test_real_proxy_child_auth_privacy_and_body_policy( }))) return asyncio.sleep(0, result=ModelResponse(id="private-summary", model="compactor")) + monkeypatch.setattr(litellm, "max_budget", 0) monkeypatch.setattr(proxy_server.app, "dependency_overrides", {}) monkeypatch.setattr(proxy_server, "master_key", "sk-master-fixture") + monkeypatch.setattr(litellm, "max_budget", 0.0) monkeypatch.setattr(proxy_server, "prisma_client", object()) monkeypatch.setattr(proxy_server, "user_api_key_cache", cache) monkeypatch.setattr(proxy_server, "llm_router", None) diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index a38470d1fdf..d2d71d6df05 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -1174,6 +1174,172 @@ class TestProxyInitializationHelpers: else: assert "pgbouncer" not in appended_params + @pytest.mark.parametrize( + "env_value, config_value, expect_pgbouncer", + [ + ("true", None, True), + ("1", None, True), + ("false", None, False), + (None, None, False), + ("true", False, True), + ("false", True, True), + ], + ) + @patch("subprocess.run") + @patch("atexit.register") + @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") + @patch( + "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False + ) + def test_disable_prepared_statements_env_var_forwarded_to_url( + self, + mock_should_update, + mock_setup_db, + mock_atexit_register, + mock_subprocess_run, + env_value, + config_value, + expect_pgbouncer, + ): + from click.testing import CliRunner + + from litellm.proxy.proxy_cli import run_server + + runner = CliRunner() + mock_subprocess_run.return_value = MagicMock(returncode=0) + + general_settings = {"database_url": "postgresql://test:test@localhost:5432/test"} + if config_value is not None: + general_settings["database_disable_prepared_statements"] = config_value + mock_proxy_module = MagicMock( + app=MagicMock(), + ProxyConfig=MagicMock(), + KeyManagementSettings=MagicMock(), + save_worker_config=MagicMock(), + ) + mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock( + return_value={"general_settings": general_settings} + ) + + clean_env = { + k: v + for k, v in os.environ.items() + if k not in ("DATABASE_URL", "DIRECT_URL", "DATABASE_DISABLE_PREPARED_STATEMENTS") + } + if env_value is not None: + clean_env["DATABASE_DISABLE_PREPARED_STATEMENTS"] = env_value + + with ( + patch.dict(os.environ, clean_env, clear=True), + patch.dict( + "sys.modules", + { + "proxy_server": mock_proxy_module, + "litellm.proxy.proxy_server": mock_proxy_module, + }, + ), + patch( + "litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args" + ) as mock_get_args, + patch( + "litellm.proxy.proxy_cli.append_query_params", + side_effect=lambda url, params: str(url), + ) as mock_append_query_params, + ): + mock_get_args.return_value = { + "app": "litellm.proxy.proxy_server:app", + "host": "localhost", + "port": 8000, + } + + result = runner.invoke( + run_server, + ["--local", "--config", "test-config.yaml", "--skip_server_startup"], + ) + + assert ( + result.exit_code == 0 + ), f"exit_code={result.exit_code}, output={result.output}" + appended_params = mock_append_query_params.call_args.args[1] + if expect_pgbouncer: + assert appended_params["pgbouncer"] == "true", appended_params + else: + assert "pgbouncer" not in appended_params, appended_params + + @patch("subprocess.run") + @patch("atexit.register") + @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") + @patch( + "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False + ) + def test_malformed_disable_prepared_statements_env_var_is_rejected_even_when_config_enables_it( + self, + mock_should_update, + mock_setup_db, + mock_atexit_register, + mock_subprocess_run, + ): + from click.testing import CliRunner + + from litellm.proxy.proxy_cli import run_server + + runner = CliRunner() + mock_subprocess_run.return_value = MagicMock(returncode=0) + + mock_proxy_module = MagicMock( + app=MagicMock(), + ProxyConfig=MagicMock(), + KeyManagementSettings=MagicMock(), + save_worker_config=MagicMock(), + ) + mock_proxy_module.ProxyConfig.return_value.get_config = AsyncMock( + return_value={ + "general_settings": { + "database_url": "postgresql://test:test@localhost:5432/test", + "database_disable_prepared_statements": True, + } + } + ) + + clean_env = { + k: v + for k, v in os.environ.items() + if k not in ("DATABASE_URL", "DIRECT_URL", "DATABASE_DISABLE_PREPARED_STATEMENTS") + } + clean_env["DATABASE_DISABLE_PREPARED_STATEMENTS"] = "enabled" + + with ( + patch.dict(os.environ, clean_env, clear=True), + patch.dict( + "sys.modules", + { + "proxy_server": mock_proxy_module, + "litellm.proxy.proxy_server": mock_proxy_module, + }, + ), + patch( + "litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args" + ) as mock_get_args, + patch( + "litellm.proxy.proxy_cli.append_query_params", + side_effect=lambda url, params: str(url), + ) as mock_append_query_params, + ): + mock_get_args.return_value = { + "app": "litellm.proxy.proxy_server:app", + "host": "localhost", + "port": 8000, + } + + result = runner.invoke( + run_server, + ["--local", "--config", "test-config.yaml", "--skip_server_startup"], + ) + + assert isinstance(result.exception, ValueError), f"exit_code={result.exit_code}, output={result.output}" + assert "DATABASE_DISABLE_PREPARED_STATEMENTS" in str(result.exception), result.exception + mock_append_query_params.assert_not_called() + @patch("uvicorn.run") @patch("atexit.register") @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 26bfd5c52bd..32b89885ac2 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -3200,7 +3200,7 @@ def test_normalize_datetime_for_sorting(): @pytest.mark.asyncio -async def test_add_proxy_budget_to_db_only_creates_user_no_keys(): +async def test_add_proxy_budget_to_db_only_creates_user_no_keys(monkeypatch: pytest.MonkeyPatch): """ Test that _add_proxy_budget_to_db only creates a user and no keys are added. @@ -3218,8 +3218,8 @@ async def test_add_proxy_budget_to_db_only_creates_user_no_keys(): from litellm.proxy.proxy_server import ProxyStartupEvent # Set up required litellm settings - litellm.budget_duration = "30d" - litellm.max_budget = 100.0 + monkeypatch.setattr(litellm, "budget_duration", "30d") + monkeypatch.setattr(litellm, "max_budget", 100.0) litellm_proxy_budget_name = "litellm-proxy-budget" @@ -3258,7 +3258,7 @@ async def test_add_proxy_budget_to_db_only_creates_user_no_keys(): @pytest.mark.asyncio -async def test_add_proxy_budget_to_db_backfills_budget_reset_at(): +async def test_add_proxy_budget_to_db_backfills_budget_reset_at(monkeypatch: pytest.MonkeyPatch): """ Test that _upsert_proxy_budget_with_reset_at_backfill issues a conditional update_many with `WHERE budget_reset_at IS NULL` to backfill the column on @@ -3276,8 +3276,8 @@ async def test_add_proxy_budget_to_db_backfills_budget_reset_at(): import litellm from litellm.proxy.proxy_server import ProxyStartupEvent - litellm.budget_duration = "30d" - litellm.max_budget = 100.0 + monkeypatch.setattr(litellm, "budget_duration", "30d") + monkeypatch.setattr(litellm, "max_budget", 100.0) litellm_proxy_budget_name = "litellm-proxy-budget" mock_prisma = MagicMock() diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index f40848bc38b..0140fcaba21 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3998,6 +3998,32 @@ class TestSyncUiSettingsToGeneralSettings: assert general_settings["forward_client_headers_to_llm_api"] is True assert general_settings.source("forward_client_headers_to_llm_api") == "db" + def test_every_runtime_flag_reaches_a_reader_once_applied(self, monkeypatch): + """A flag the settings rules do not route to the ui_settings row is stored but never read back.""" + from litellm.proxy import proxy_server + from litellm.proxy.config_resolvers import SettingsStore + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, + _RUNTIME_GENERAL_SETTINGS_FLAGS, + apply_runtime_general_settings_flags, + ) + + general_settings = SettingsStore("general_settings") + general_settings.load_yaml({}) + monkeypatch.setattr(proxy_server, "general_settings", general_settings) + + stored = { + key: (["tpm_limit"] if key == TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING else True) + for key in _RUNTIME_GENERAL_SETTINGS_FLAGS + } + assert stored + + apply_runtime_general_settings_flags(stored) + + read_back = {key: general_settings.get(key) for key in stored} + + assert read_back == stored + def test_applied_runtime_flags_cannot_override_the_config_file(self, monkeypatch): from litellm.proxy import proxy_server from litellm.proxy.config_resolvers import SettingsStore diff --git a/tests/test_litellm/proxy/utils/proxy_logging/test_post_call_failure_hook.py b/tests/test_litellm/proxy/utils/proxy_logging/test_post_call_failure_hook.py index a4bb7d63548..d7c52c528eb 100644 --- a/tests/test_litellm/proxy/utils/proxy_logging/test_post_call_failure_hook.py +++ b/tests/test_litellm/proxy/utils/proxy_logging/test_post_call_failure_hook.py @@ -5,16 +5,18 @@ from __future__ import annotations import asyncio from datetime import datetime -from typing import Any, Final +from types import MappingProxyType +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException import litellm +from litellm.constants import PROXY_REJECTED_BEFORE_ROUTING_KEY from litellm.exceptions import GuardrailRaisedException from litellm.integrations.custom_logger import CustomLogger -from litellm.proxy._types import AlertType, ProxyErrorTypes, UserAPIKeyAuth +from litellm.proxy._types import ProxyErrorTypes, UserAPIKeyAuth from litellm.proxy.utils import ProxyLogging @@ -99,6 +101,456 @@ async def test_post_call_failure_hook_no_callbacks_returns_none( } +@pytest.mark.asyncio +async def test_post_call_failure_hook_attributes_single_router_deployment( + proxy_logging, make_user_api_key_auth, monkeypatch +): + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + "model_info": {"provider": "acme"}, + } + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={"model": "internal-model", "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=403, detail="blocked"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs["custom_llm_provider"] == "openai" + assert kwargs["litellm_params"]["custom_llm_provider"] == "openai" + assert kwargs["litellm_params"]["metadata"]["model_info"]["provider"] == "acme" + assert kwargs["litellm_params"]["metadata"]["deployment"] == "openai/gpt-4.1" + assert kwargs["litellm_params"][PROXY_REJECTED_BEFORE_ROUTING_KEY] is True + assert kwargs["standard_logging_object"]["custom_llm_provider"] == "openai" + assert ( + kwargs["standard_logging_object"]["model_id"] == proxy_server.llm_router.get_model_list()[0]["model_info"]["id"] + ) + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_keeps_router_stamped_metadata_for_post_call_failures( + proxy_logging, make_user_api_key_auth, monkeypatch +): + """A post-call guardrail block arrives after the provider handoff with the router's own + ``model_info`` in the request metadata. The pre-routing flag must stay off so deployment + metrics keep attributing the failure to the deployment that actually served the call.""" + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + "model_info": {"id": "routed-deployment"}, + } + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + request_data = { + "litellm_call_id": "post-call-guardrail", + "model": "internal-model", + "messages": [{"role": "user", "content": "hi"}], + "metadata": {"model_info": {"id": "routed-deployment", "served": True}}, + } + logging_obj, request_data = litellm.utils.function_setup( + original_function="acompletion", rules_obj=litellm.utils.Rules(), start_time=datetime.now(), **request_data + ) + logging_obj.model_call_details["first_api_call_start_time"] = datetime.now() + request_data["litellm_logging_obj"] = logging_obj + + await proxy_logging.post_call_failure_hook( + request_data=request_data, + original_exception=GuardrailRaisedException(guardrail_name="g", message="response blocked"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs["litellm_params"]["metadata"]["model_info"] == {"id": "routed-deployment", "served": True} + assert PROXY_REJECTED_BEFORE_ROUTING_KEY not in kwargs["litellm_params"] + assert kwargs["standard_logging_object"]["model_id"] == "routed-deployment" + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_flags_pre_routing_reject_despite_caller_model_info( + proxy_logging, make_user_api_key_auth, monkeypatch +): + """A key allowed to override pricing keeps caller-supplied ``metadata.model_info``. A reject + before any provider handoff must still carry the pre-routing flag so deployment metrics do + not record an outage for a deployment the request never reached.""" + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + "model_info": {"id": "real-deployment"}, + } + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={ + "model": "internal-model", + "messages": [{"role": "user", "content": "hi"}], + "metadata": {"model_info": {"id": "spoofed-deployment"}}, + }, + original_exception=HTTPException(status_code=429, detail="key over limit"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs["litellm_params"][PROXY_REJECTED_BEFORE_ROUTING_KEY] is True + assert kwargs["litellm_params"]["custom_llm_provider"] == "openai" + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_attribution_does_not_count_against_the_deployment( + proxy_logging, make_user_api_key_auth, monkeypatch +): + """The router's failure callbacks run on this path too. A proxy-side reject must not + bump the deployment's failure or rpm counters, or a key hitting its own limit + could cool down the only deployment for everyone.""" + from litellm.proxy import proxy_server + + router = litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test", "rpm": 100}, + } + ] + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + proxy_logging.alert_types = [] + deployment_id = router.get_model_list()[0]["model_info"]["id"] + + for status in (403, 429): + await proxy_logging.post_call_failure_hook( + request_data={"model": "internal-model", "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=status, detail="blocked"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + pending = asyncio.all_tasks() - {asyncio.current_task()} + await asyncio.gather(*pending, return_exceptions=True) + + deployment_keys = [key for key in router.cache.in_memory_cache.cache_dict if deployment_id in key] + assert deployment_keys == [], f"proxy reject was counted against the deployment: {deployment_keys}" + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_attributes_the_keys_team_deployment_over_the_global_group( + proxy_logging, make_user_api_key_auth, monkeypatch +): + """A team key requesting its team public model name must be attributed to the team's + deployment, not to a global group that happens to share the public name.""" + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "shared-name", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + "model_info": {"id": "global-deployment"}, + }, + { + "model_name": "shared-name_test-team_deadbeef", + "litellm_params": {"model": "anthropic/claude-sonnet-4-5", "api_key": "sk-test"}, + "model_info": { + "id": "team-deployment", + "team_id": "test-team", + "team_public_model_name": "shared-name", + }, + }, + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={"model": "shared-name", "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=429, detail="rate limited"), + user_api_key_dict=make_user_api_key_auth(team_id="test-team", request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs["custom_llm_provider"] == "anthropic" + assert kwargs["litellm_params"]["metadata"]["deployment"] == "anthropic/claude-sonnet-4-5" + assert kwargs["standard_logging_object"]["model_id"] == "team-deployment" + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_omits_provider_for_mixed_router_deployments( + proxy_logging, make_user_api_key_auth, monkeypatch +): + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + }, + { + "model_name": "internal-model", + "litellm_params": {"model": "anthropic/claude-sonnet-4-5", "api_key": "sk-test"}, + }, + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={"model": "internal-model", "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=403, detail="blocked"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs.get("custom_llm_provider") is None + assert "model_info" not in (kwargs["litellm_params"].get("metadata") or {}) + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_omits_provider_when_a_deployment_does_not_resolve( + proxy_logging, make_user_api_key_auth, monkeypatch +): + """One deployment resolves to openai and its sibling resolves to nothing: the group + is not known to be single-provider, so no provider is stamped on the failure.""" + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + router = MagicMock() + router.get_model_list.return_value = [ + {"model_name": "internal-model", "litellm_params": {"model": "openai/gpt-4.1"}}, + {"model_name": "internal-model", "litellm_params": {"model": "unmapped-model-with-no-provider"}}, + ] + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={"model": "internal-model", "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=403, detail="blocked"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs.get("custom_llm_provider") is None + assert kwargs["litellm_params"].get("custom_llm_provider") is None + + +@pytest.mark.asyncio +async def test_handle_logging_proxy_only_path_attributes_with_read_only_metadata( + proxy_logging, make_user_api_key_auth, monkeypatch +): + """With a logging object already on the request, its metadata is taken as given; + a read-only mapping there must not crash the stamp, and the failure handler + still receives the provider attribution.""" + from litellm.proxy import proxy_server + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + "model_info": {"provider": "acme"}, + } + ] + ), + ) + logging_obj = MagicMock() + logging_obj.call_type = "acompletion" + logging_obj.model_call_details = {} + logging_obj.async_failure_handler = AsyncMock() + + await proxy_logging._handle_logging_proxy_only_error( + request_data={ + "litellm_logging_obj": logging_obj, + "model": "internal-model", + "messages": [{"role": "user", "content": "hi"}], + "metadata": MappingProxyType({"user_api_key_alias": "frozen"}), + }, + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + original_exception=HTTPException(status_code=403, detail="blocked"), + ) + + assert logging_obj.async_failure_handler.called + update_kwargs = logging_obj.update_environment_variables.call_args.kwargs + assert update_kwargs["custom_llm_provider"] == "openai" + assert update_kwargs["litellm_params"]["custom_llm_provider"] == "openai" + assert update_kwargs["litellm_params"]["metadata"] == {"user_api_key_alias": "frozen"} + + +@pytest.mark.asyncio +async def test_post_call_failure_hook_fires_without_router_attribution( + proxy_logging, make_user_api_key_auth, monkeypatch +): + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "different-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + } + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={"model": "internal-model", "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=403, detail="blocked"), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs.get("custom_llm_provider") is None + assert "model_info" not in (kwargs["litellm_params"].get("metadata") or {}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model", [123, ["internal-model"], {"name": "internal-model"}, None]) +async def test_post_call_failure_hook_fires_for_non_string_model( + proxy_logging, make_user_api_key_auth, monkeypatch, model: object +): + """A body whose ``model`` is not a string is rejected by the proxy before routing; its + failure callback must still fire, unattributed, instead of a TypeError escaping the hook.""" + from litellm.proxy import proxy_server + + recorded: list[dict] = [] + + class _RecordingLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + recorded.append(kwargs) + + monkeypatch.setattr( + proxy_server, + "llm_router", + litellm.Router( + model_list=[ + { + "model_name": "internal-model", + "litellm_params": {"model": "openai/gpt-4.1", "api_key": "sk-test"}, + } + ] + ), + ) + monkeypatch.setattr(litellm, "callbacks", [_RecordingLogger()]) + proxy_logging.alert_types = [] + + await proxy_logging.post_call_failure_hook( + request_data={"model": model, "messages": [{"role": "user", "content": "hi"}]}, + original_exception=HTTPException(status_code=400, detail="'model' must be a string."), + user_api_key_dict=make_user_api_key_auth(request_route="/chat/completions"), + route="/chat/completions", + ) + + assert len(recorded) == 1 + kwargs = recorded[0] + assert kwargs.get("custom_llm_provider") is None + assert "model_info" not in (kwargs["litellm_params"].get("metadata") or {}) + + @pytest.mark.asyncio async def test_post_call_failure_hook_callback_returns_http_exception( proxy_logging, make_user_api_key_auth, monkeypatch diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index 9c6b2404d1e..c7557f610f6 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -1,15 +1,16 @@ -from contextlib import nullcontext - +import importlib import subprocess import sys import textwrap import types +from contextlib import nullcontext +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException +from mcp.types import CallToolResult, TextContent from openai.types.responses.tool_param import Mcp -import importlib from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing from litellm.responses import main as responses_main @@ -17,10 +18,9 @@ from litellm.responses.mcp import litellm_proxy_mcp_handler as mcp_handler_modul from litellm.responses.mcp.litellm_proxy_mcp_handler import ( LiteLLM_Proxy_MCP_Handler, ) -from typing import Any, cast from litellm.types.llms.openai import ResponsesAPIResponse -from litellm.types.utils import ModelResponse from litellm.types.responses.main import OutputFunctionToolCall +from litellm.types.utils import ModelResponse class _DummyMCPResult: @@ -496,6 +496,157 @@ async def test_execute_tool_calls_threads_logging_obj_into_call_tool(monkeypatch assert call_tool_mock.await_args.kwargs["litellm_logging_obj"] is sentinel_logging_obj +@pytest.mark.asyncio +async def test_execute_tool_calls_applies_post_call_hook_content(monkeypatch): + proxy_module = types.SimpleNamespace(proxy_logging_obj=None) + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_module) + + result = CallToolResult( + content=[TextContent(type="text", text="SECRET-1234")], + structuredContent={"result": "SECRET-1234"}, + isError=False, + ) + fake_manager = types.SimpleNamespace( + get_registry=MagicMock(return_value={}), + call_tool=AsyncMock(return_value=result), + _get_mcp_server_from_tool_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(return_value=None), + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + fake_manager, + ) + + logging_obj = MagicMock() + logging_obj.model_call_details = {} + logging_obj.async_post_mcp_tool_call_hook = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="[REDACTED]")], is_error=True)) + logging_obj.async_success_handler = AsyncMock() + handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler") + monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (logging_obj, None)) + + tool_name = "deepwiki-read_wiki_structure" + results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( + tool_server_map={tool_name: "deepwiki"}, + tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}], + user_api_key_auth=None, + ) + + assert results == [{"tool_call_id": "call-1", "result": "[REDACTED]", "name": tool_name}] + assert logging_obj.async_success_handler.await_args.kwargs["result"].content[0].text == "[REDACTED]" + assert logging_obj.async_success_handler.await_args.kwargs["result"].structured_content is None + + +@pytest.mark.asyncio +async def test_execute_tool_calls_returns_proxy_result_without_logging(monkeypatch): + result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_mcp_call_hook = AsyncMock(side_effect=lambda response, **_: response) + monkeypatch.setitem( + sys.modules, "litellm.proxy.proxy_server", types.SimpleNamespace(proxy_logging_obj=proxy_logging_obj) + ) + + fake_manager = types.SimpleNamespace( + get_registry=MagicMock(return_value={}), + call_tool=AsyncMock(return_value=result), + _get_mcp_server_from_tool_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(return_value=None), + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + fake_manager, + ) + handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler") + monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (None, None)) + + tool_name = "deepwiki-read_wiki_structure" + results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( + tool_server_map={tool_name: "deepwiki"}, + tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}], + user_api_key_auth=None, + ) + + assert results == [{"tool_call_id": "call-1", "result": "ok", "name": tool_name}] + proxy_logging_obj.post_mcp_call_hook.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_execute_tool_calls_passes_logging_details_to_proxy_hook(monkeypatch): + result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_mcp_call_hook = AsyncMock(side_effect=lambda response, **_: response) + monkeypatch.setitem( + sys.modules, "litellm.proxy.proxy_server", types.SimpleNamespace(proxy_logging_obj=proxy_logging_obj) + ) + + fake_manager = types.SimpleNamespace( + get_registry=MagicMock(return_value={}), + call_tool=AsyncMock(return_value=result), + _get_mcp_server_from_tool_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(return_value=None), + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + fake_manager, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {"request_id": "request-1"} + logging_obj.async_post_mcp_tool_call_hook = AsyncMock(return_value=result) + logging_obj.async_success_handler = AsyncMock() + handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler") + monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (logging_obj, None)) + + tool_name = "deepwiki-read_wiki_structure" + results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( + tool_server_map={tool_name: "deepwiki"}, + tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}], + user_api_key_auth=None, + ) + + assert results == [{"tool_call_id": "call-1", "result": "ok", "name": tool_name}] + assert proxy_logging_obj.post_mcp_call_hook.await_args.kwargs["request_data"] == logging_obj.model_call_details + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure_stage", ["post_call_hook", "success_handler"]) +async def test_execute_tool_calls_continues_when_post_call_logging_fails(monkeypatch, failure_stage: str): + proxy_module = types.SimpleNamespace(proxy_logging_obj=None) + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_module) + + result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + fake_manager = types.SimpleNamespace( + get_registry=MagicMock(return_value={}), + call_tool=AsyncMock(return_value=result), + _get_mcp_server_from_tool_name=MagicMock(return_value=None), + get_mcp_server_by_name=MagicMock(return_value=None), + ) + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager", + fake_manager, + ) + + logging_obj = MagicMock() + logging_obj.model_call_details = {} + logging_obj.post_call = MagicMock() + logging_obj.async_post_mcp_tool_call_hook = AsyncMock( + side_effect=RuntimeError("hook failed") if failure_stage == "post_call_hook" else None, + return_value=result, + ) + logging_obj.async_success_handler = AsyncMock( + side_effect=RuntimeError("success logging failed") if failure_stage == "success_handler" else None + ) + handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler") + monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (logging_obj, None)) + + tool_name = "deepwiki-read_wiki_structure" + results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls( + tool_server_map={tool_name: "deepwiki"}, + tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}], + user_api_key_auth=None, + ) + + assert results == [{"tool_call_id": "call-1", "result": "ok", "name": tool_name}] + + @pytest.mark.asyncio async def test_get_mcp_tools_from_manager_enables_list_tools_logging(monkeypatch): """ diff --git a/tests/test_litellm/responses/test_metadata_codex_callback.py b/tests/test_litellm/responses/test_metadata_codex_callback.py index f151f36be63..d5c411d63db 100644 --- a/tests/test_litellm/responses/test_metadata_codex_callback.py +++ b/tests/test_litellm/responses/test_metadata_codex_callback.py @@ -48,72 +48,6 @@ class MetadataCaptureCallback(CustomLogger): self.event.set() -@pytest.mark.asyncio -async def test_metadata_passed_to_custom_callback_codex_models(): - """ - Test that metadata passed to completion() is available in custom callback - when using codex models (responses API bridge path). - - Codex models have mode=responses and route through responses_api_bridge, - which passes litellm_metadata. The fix ensures this is preserved as - litellm_params.metadata for callback compatibility. - """ - from litellm.types.llms.openai import ResponsesAPIResponse - - mock_response = ResponsesAPIResponse.model_construct( - id="resp-test", - created_at=0, - output=[ - { - "type": "message", - "id": "msg-1", - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": "Hello!"}], - } - ], - object="response", - model="gpt-5.1-codex", - status="completed", - usage={ - "input_tokens": 5, - "output_tokens": 10, - "total_tokens": 15, - }, - ) - - test_metadata = {"foo": "bar", "trace_id": "test-123"} - callback = MetadataCaptureCallback() - original_callbacks = litellm.callbacks.copy() if litellm.callbacks else [] - litellm.callbacks = [callback] - - try: - with patch( - "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", - new_callable=AsyncMock, - ) as mock_post: - mock_post.return_value = _make_mock_http_response( - mock_response.model_dump() - ) - # gpt-5.1-codex has mode=responses - routes through responses bridge - await litellm.acompletion( - model="gpt-5.1-codex", - messages=[{"role": "user", "content": "Hello"}], - metadata=test_metadata, - ) - - await asyncio.wait_for(callback.event.wait(), timeout=5.0) - - assert callback.captured_kwargs is not None, "Callback should have been invoked" - - litellm_params = callback.captured_kwargs.get("litellm_params", {}) - metadata = litellm_params.get("metadata") or {} - - assert "foo" in metadata, "metadata['foo'] should be accessible in callback" - assert metadata["foo"] == "bar" - assert metadata.get("trace_id") == "test-123" - finally: - litellm.callbacks = original_callbacks @pytest.mark.asyncio diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index 16135106b41..642495fab86 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -66,6 +66,30 @@ class TestUseResponsesApiBridgeFlag: mock_bridge_handler.assert_called_once() + @patch.object( + import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" + ) + @patch.object( + import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config" + ) + def test_provider_affinity_header_is_forwarded_through_bridge(self, mock_get_config, mock_bridge_handler): + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_chat_completions_api=True, + litellm_session_id="session-bridge", + provider_affinity_header="X-Conversation-Id", + extra_headers={"X-Customer-Header": "customer-value"}, + litellm_logging_obj=MagicMock(), + ) + + forwarded_headers = mock_bridge_handler.call_args.kwargs["extra_headers"] + assert forwarded_headers["X-Conversation-Id"] == "session-bridge" + assert forwarded_headers["X-Customer-Header"] == "customer-value" + @patch.object( import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler" ) diff --git a/tests/test_litellm/router_strategy/test_router_routing_groups.py b/tests/test_litellm/router_strategy/test_router_routing_groups.py index 506563a82fb..425f68dda18 100644 --- a/tests/test_litellm/router_strategy/test_router_routing_groups.py +++ b/tests/test_litellm/router_strategy/test_router_routing_groups.py @@ -9,7 +9,8 @@ import asyncio import datetime import time import uuid -from collections.abc import Callable +from collections.abc import Callable, Mapping +from typing import Final, Literal from unittest.mock import patch import pytest @@ -18,7 +19,7 @@ from pydantic import ValidationError import litellm from litellm import Router from litellm.integrations.custom_logger import CustomLogger -from litellm.types.router import RoutingGroup, RoutingStrategy +from litellm.types.router import DeploymentTypedDict, FallbackAccessCheck, RoutingGroup, RoutingStrategy from litellm.utils import Rules, function_setup @@ -1676,3 +1677,475 @@ async def test_group_call_429_cools_down_member_across_retries(): ) cooldown_ids = await _call_and_get_cooldowns(router, "quality") assert "deploy-3" in cooldown_ids + + +def _priority_group( + name: str = "priority-group", primary: int = 1, backup: int = 2 +) -> RoutingGroup: + return RoutingGroup.model_validate({ + "group_name": name, + "models": ["filtered-model", "other-model"], + "routing_strategy": "priority", + "model_priorities": {"filtered-model": primary, "other-model": backup}, + }) + + +def _priority_deployments( + primary_response: str = "primary", primary_blocked: bool = False, backup_response: str = "backup" +) -> list[DeploymentTypedDict]: + return [ + { + **deployment, + "litellm_params": { + **deployment["litellm_params"], + "mock_response": ( + primary_response if deployment["model_name"] == "filtered-model" else backup_response + ), + "order": 10 if deployment["model_name"] == "filtered-model" else 1, + }, + "model_info": { + **deployment["model_info"], + "blocked": primary_blocked and deployment["model_name"] == "filtered-model", + }, + } + for deployment in _model_list() + ] + + +def test_priority_group_affinity_scope_follows_group_aliases_and_settings_reload() -> None: + router: Final = Router( + model_list=_priority_deployments(), + routing_groups=[ + _priority_group(), + RoutingGroup(group_name="legacy", models=["filtered-model"], routing_strategy="simple-shuffle"), + ], + model_group_alias={"priority-alias": "priority-group"}, + ) + requested_models: Final = ( + "priority-group", "priority-alias", "filtered-model", "other-model", "legacy", "missing" + ) + assert tuple(router._is_priority_routing_group(model) for model in requested_models) == ( + True, True, False, False, False, False + ) + + router.update_settings(routing_groups=[{ + "group_name": "priority-group", + "models": ["filtered-model", "other-model"], + "routing_strategy": "simple-shuffle", + }]) + assert tuple(router._is_priority_routing_group(model) for model in requested_models) == (False,) * 6 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("requested_model", ["priority-group", "priority-alias"]) +@pytest.mark.asyncio +async def test_priority_group_always_uses_primary_when_healthy( + asynchronous: bool, requested_model: str +) -> None: + router: Final = Router( + model_list=_priority_deployments(), + routing_groups=[_priority_group()], + model_group_alias={"priority-alias": "priority-group"}, + num_retries=0, + ) + request: Final = {"model": requested_model, "messages": [{"role": "user", "content": "hi"}]} + response: Final = ( + await router.acompletion(**request) if asynchronous else router.completion(**request) + ) + assert response.choices[0].message.content == "primary" + + +@pytest.mark.parametrize("requested_model", ["priority-group", "priority-alias"]) +@pytest.mark.asyncio +async def test_priority_group_fails_over_without_retries_and_leaves_direct_calls_unchanged( + requested_model: str, +) -> None: + router: Final = Router( + model_list=_priority_deployments(primary_response="litellm.RateLimitError"), + routing_groups=[_priority_group()], + model_group_alias={"priority-alias": "priority-group"}, + num_retries=0, + disable_cooldowns=True, + ) + response: Final = await router.acompletion( + model=requested_model, messages=[{"role": "user", "content": "hi"}] + ) + assert response.choices[0].message.content == "backup" + with pytest.raises(litellm.RateLimitError): + await router.acompletion(model="filtered-model", messages=[{"role": "user", "content": "hi"}]) + + +@pytest.mark.asyncio +async def test_opposite_priority_groups_preserve_deployments_and_legacy_member_policy() -> None: + router: Final = Router( + model_list=_priority_deployments(), + routing_strategy="latency-based-routing", + routing_groups=[ + RoutingGroup(group_name="legacy", models=["filtered-model"], routing_strategy="least-busy"), + _priority_group(), + _priority_group("reverse-group", primary=2, backup=1), + ], + ) + before: Final = router.get_model_list(model_name="filtered-model") + forward: Final = await router.acompletion( + model="priority-group", messages=[{"role": "user", "content": "hi"}] + ) + reverse: Final = await router.acompletion( + model="reverse-group", messages=[{"role": "user", "content": "hi"}] + ) + assert (forward.choices[0].message.content, reverse.choices[0].message.content) == ("primary", "backup") + assert router._get_routing_context("filtered-model")[0] == "least-busy" + assert router._get_routing_context("other-model")[0] == "latency-based-routing" + assert router.get_model_list(model_name="filtered-model") == before + assert all(deployment["litellm_params"]["order"] == 10 for deployment in before) + + +@pytest.mark.parametrize("backup_priority, expected", [(1, "backup"), (2, "primary")]) +@pytest.mark.asyncio +async def test_priority_group_weights_select_only_within_the_first_eligible_level( + backup_priority: int, expected: str +) -> None: + router: Final = Router( + model_list=_priority_deployments(), routing_groups=[_priority_group(backup=backup_priority)] + ) + response: Final = await router.acompletion( + model="priority-group", + messages=[{"role": "user", "content": "hi"}], + _router_weights={"priority-group": {"deploy-1": 0, "deploy-2": 0, "deploy-3": 1}}, + ) + assert response.choices[0].message.content == expected + + +@pytest.mark.asyncio +async def test_priority_group_skips_paused_primary_and_returns_to_it_after_recovery() -> None: + router: Final = Router( + model_list=_priority_deployments(primary_blocked=True), routing_groups=[_priority_group()] + ) + paused: Final = await router.acompletion( + model="priority-group", messages=[{"role": "user", "content": "hi"}] + ) + router.set_model_list(_priority_deployments()) + recovered: Final = await router.acompletion( + model="priority-group", messages=[{"role": "user", "content": "hi"}] + ) + assert (paused.choices[0].message.content, recovered.choices[0].message.content) == ("backup", "primary") + + +@pytest.mark.parametrize("controls", [{"disable_fallbacks": True}, {"max_fallbacks": 0}]) +@pytest.mark.asyncio +async def test_priority_group_respects_request_fallback_controls(controls: dict[str, object]) -> None: + router: Final = Router( + model_list=_priority_deployments(primary_response="litellm.RateLimitError"), + routing_groups=[_priority_group()], + num_retries=0, + disable_cooldowns=True, + ) + with pytest.raises(litellm.RateLimitError): + await router.acompletion( + model="priority-group", messages=[{"role": "user", "content": "hi"}], **controls + ) + + +@pytest.mark.parametrize( + "priorities", + [None, {}, {"filtered-model": 1}, {"filtered-model": 1, "other-model": 2, "extra": 3}], +) +def test_priority_group_requires_exact_member_priorities(priorities: object) -> None: + with pytest.raises(ValidationError): + RoutingGroup.model_validate({ + "group_name": "priority-group", + "models": ["filtered-model", "other-model"], + "routing_strategy": "priority", + "model_priorities": priorities, + }) + + +@pytest.mark.parametrize("priority", [True, 0, -1, 1.5, "1", 9007199254740992]) +def test_priority_group_rejects_invalid_priority_values(priority: object) -> None: + with pytest.raises(ValidationError): + RoutingGroup.model_validate({ + "group_name": "priority-group", + "models": ["filtered-model"], + "routing_strategy": "priority", + "model_priorities": {"filtered-model": priority}, + }) + + +@pytest.mark.parametrize("models", [[], ["filtered-model", "filtered-model"]]) +def test_priority_group_requires_nonempty_unique_members(models: list[str]) -> None: + with pytest.raises(ValidationError): + RoutingGroup.model_validate({ + "group_name": "priority-group", + "models": models, + "routing_strategy": "priority", + "model_priorities": {model: 1 for model in models}, + }) + + +@pytest.mark.parametrize( + "changes", [{"routing_strategy": "simple-shuffle"}, {"routing_strategy_args": {"ttl": 60}}] +) +def test_priority_group_rejects_conflicting_strategy_settings(changes: dict[str, object]) -> None: + with pytest.raises(ValidationError): + RoutingGroup.model_validate({ + "group_name": "priority-group", + "models": ["filtered-model"], + "routing_strategy": "priority", + "model_priorities": {"filtered-model": 1}, + **changes, + }) + + +@pytest.mark.parametrize("group_name", ["filtered-model", "priority-alias"]) +def test_priority_group_rejects_names_shadowed_by_a_model_or_alias(group_name: str) -> None: + with pytest.raises(ValueError, match=r"shadow|collid|conflict"): + Router( + model_list=_priority_deployments(), + routing_groups=[_priority_group(name=group_name)], + model_group_alias={"priority-alias": "filtered-model"}, + ) + + +def test_priority_is_rejected_as_a_top_level_strategy() -> None: + with pytest.raises(ValueError, match="routing_strategy"): + _build_router(routing_strategy="priority") + + +@pytest.mark.asyncio +async def test_priority_group_settings_roundtrip_replaces_the_order() -> None: + router: Final = Router(model_list=_priority_deployments(), routing_groups=[_priority_group()]) + replacement: Final = _priority_group(primary=9007199254740991, backup=1) + router.update_settings(routing_groups=[replacement.model_dump()]) + stored: Final = router.get_settings()["routing_groups"] + assert stored == [replacement.model_dump()] + response: Final = await router.acompletion( + model="priority-group", messages=[{"role": "user", "content": "hi"}] + ) + assert response.choices[0].message.content == "backup" + + +def _priority_auto_router_deployment() -> DeploymentTypedDict: + return { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "classifier_type": "heuristic", + "adaptive": False, + "tiers": { + tier: "priority-group" + for tier in ("SIMPLE", "MEDIUM", "COMPLEX", "REASONING") + }, + }, + "complexity_router_default_model": "filtered-model", + }, + } + + +@pytest.mark.asyncio +async def test_auto_router_selected_priority_group_fails_over_inside_the_group() -> None: + router: Final = Router( + model_list=[ + *_priority_deployments(primary_response="litellm.RateLimitError"), + _priority_auto_router_deployment(), + ], + routing_groups=[_priority_group()], + num_retries=0, + disable_cooldowns=True, + ) + response: Final = await router.acompletion( + model="smart-router", messages=[{"role": "user", "content": "hi"}] + ) + assert response.choices[0].message.content == "backup" + + +@pytest.mark.parametrize("endpoint_request", [{"input": "hi"}, {"messages": [{"role": "user", "content": "hi"}]}]) +@pytest.mark.asyncio +async def test_priority_generic_fallback_keeps_routing_controls_out_of_provider_kwargs( + endpoint_request: dict[str, object], +) -> None: + async def provider(model: str, **provider_kwargs: object) -> str: + assert "_target_order" not in provider_kwargs + assert "model_priorities" not in provider_kwargs + model_info: Final = provider_kwargs["model_info"] + assert isinstance(model_info, dict) + if model_info["id"] != "deploy-3": + raise litellm.RateLimitError(message="primary refused", model=model, llm_provider="openai") + return "backup" + + router: Final = Router( + model_list=_priority_deployments(), + routing_groups=[_priority_group()], + num_retries=0, + disable_cooldowns=True, + ) + response: Final = await router._ageneric_api_call_with_fallbacks( + model="priority-group", original_function=provider, **endpoint_request + ) + assert response == "backup" + + +@pytest.mark.parametrize("provider_model_id", [None, "resolved-downstream-id"]) +@pytest.mark.asyncio +async def test_priority_generic_fallback_dict_preserves_served_model_id(provider_model_id: str | None) -> None: + async def provider(model: str, **provider_kwargs: object) -> dict[str, object]: + model_info: Final = provider_kwargs["model_info"] + assert isinstance(model_info, dict) + if model_info["id"] == "deploy-1": + raise litellm.NotFoundError(message="primary missing", model=model, llm_provider="openai") + assert model_info["id"] == "deploy-3" + return { + "content": "backup", + **({"_hidden_params": {"model_id": provider_model_id}} if provider_model_id is not None else {}), + } + + router: Final = Router( + model_list=_priority_deployments()[::2], + routing_groups=[_priority_group()], + num_retries=0, + disable_cooldowns=True, + ) + outer_metadata: Final[dict[str, object]] = {} + response: Final = await router._ageneric_api_call_with_fallbacks( + model="priority-group", + original_function=provider, + messages=[{"role": "user", "content": "hi"}], + litellm_metadata=outer_metadata, + ) + primary_info: Final = outer_metadata["model_info"] + assert isinstance(primary_info, dict) + assert primary_info["id"] == "deploy-1" + assert response["content"] == "backup" + assert response["_hidden_params"]["model_id"] == (provider_model_id or "deploy-3") + assert response["_hidden_params"]["additional_headers"]["x-litellm-attempted-fallbacks"] == 1 + + +@pytest.mark.asyncio +async def test_priority_group_ignores_cached_backup_affinity() -> None: + from litellm.router_utils.prompt_caching_cache import PromptCachingCache + + router: Final = Router( + model_list=_priority_deployments(), + routing_groups=[_priority_group()], + optional_pre_call_checks=["prompt_caching"], + ) + messages: Final = [{ + "role": "user", + "content": [{"type": "text", "text": "word " * 5000, "cache_control": {"type": "ephemeral"}}], + }] + cache: Final = PromptCachingCache(cache=router.cache) + await cache.async_add_model_id(model_id="deploy-3", messages=messages, tools=None) + assert await cache.async_get_model_id(messages=messages, tools=None) == {"model_id": "deploy-3"} + response: Final = await router.acompletion(model="priority-group", messages=messages) + assert response.choices[0].message.content == "primary" + + +@pytest.mark.asyncio +async def test_priority_group_preserves_responses_continuity_on_a_backup() -> None: + from litellm.responses.utils import ResponsesAPIRequestUtils + + router: Final = Router( + model_list=_priority_deployments(), + routing_groups=[_priority_group()], + optional_pre_call_checks=["responses_api_deployment_check"], + ) + previous_response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id( + custom_llm_provider="openai", model_id="deploy-3", response_id="resp-prior" + ) + deployment: Final = await router.async_get_available_deployment( + model="priority-group", input="continue", request_kwargs={"previous_response_id": previous_response_id} + ) + assert deployment["model_info"]["id"] == "deploy-3" + + +def _recording_fallback_gate(allowed: frozenset[str], checked: list[str]) -> FallbackAccessCheck: + async def check(*, model: str, request_kwargs: Mapping[str, object], llm_router: Router) -> bool: + checked.append(model) + return model in allowed + + return check + + +@pytest.mark.parametrize("budget_admits_group", [False, True]) +@pytest.mark.asyncio +async def test_auto_priority_advance_preserves_access_scope_and_checks_paid_group_budget( + budget_admits_group: bool, +) -> None: + access_checks: Final[list[str]] = [] + budget_checks: Final[list[str]] = [] + router: Final = Router( + model_list=[ + *_priority_deployments(primary_response="litellm.RateLimitError"), + _priority_auto_router_deployment(), + ], + routing_groups=[_priority_group()], + num_retries=0, + disable_cooldowns=True, + fallback_access_check=_recording_fallback_gate(frozenset({"smart-router"}), access_checks), + fallback_budget_check=_recording_fallback_gate( + frozenset({"priority-group"}) if budget_admits_group else frozenset(), budget_checks + ), + ) + if budget_admits_group: + response: Final = await router.acompletion( + model="smart-router", messages=[{"role": "user", "content": "hi"}] + ) + assert response.choices[0].message.content == "backup" + else: + with pytest.raises(litellm.RateLimitError): + await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + assert access_checks == [] + assert budget_checks == ["priority-group"] + + +@pytest.mark.asyncio +async def test_auto_priority_group_exhaustion_still_checks_external_fallback_access() -> None: + access_checks: Final[list[str]] = [] + router: Final = Router( + model_list=[ + *_priority_deployments( + primary_response="litellm.RateLimitError", backup_response="litellm.RateLimitError" + ), + _priority_auto_router_deployment(), + { + **_model_list()[2], + "model_name": "external", + "litellm_params": {**_model_list()[2]["litellm_params"], "mock_response": "external"}, + "model_info": {"id": "external-deployment"}, + }, + ], + routing_groups=[_priority_group()], + fallbacks=[{"priority-group": ["external"]}], + num_retries=0, + disable_cooldowns=True, + fallback_access_check=_recording_fallback_gate(frozenset({"smart-router"}), access_checks), + ) + with pytest.raises(litellm.RateLimitError): + await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + assert access_checks and set(access_checks) == {"external"} + + +@pytest.mark.parametrize("check_kind", ["access", "budget"]) +@pytest.mark.parametrize("metadata_bucket", ["metadata", "litellm_metadata"]) +@pytest.mark.asyncio +async def test_caller_cannot_spoof_a_priority_group_to_bypass_fallback_gates( + check_kind: Literal["access", "budget"], metadata_bucket: str, +) -> None: + checked: Final[list[str]] = [] + check: Final = _recording_fallback_gate(frozenset({"filtered-model"}), checked) + router: Final = Router( + model_list=_priority_deployments(primary_response="litellm.RateLimitError"), + routing_groups=[_priority_group()], + fallbacks=[{"filtered-model": ["priority-group"]}], + num_retries=0, + disable_cooldowns=True, + fallback_access_check=check if check_kind == "access" else None, + fallback_budget_check=check if check_kind == "budget" else None, + ) + with pytest.raises(litellm.RateLimitError): + await router.acompletion( + model="filtered-model", + messages=[{"role": "user", "content": "hi"}], + **{metadata_bucket: {"pre_routing_selected_model": "priority-group"}}, + ) + assert checked == ["priority-group"] diff --git a/tests/test_litellm/router_utils/test_access_windows.py b/tests/test_litellm/router_utils/test_access_windows.py new file mode 100644 index 00000000000..a797a788b4e --- /dev/null +++ b/tests/test_litellm/router_utils/test_access_windows.py @@ -0,0 +1,190 @@ +from datetime import datetime, time, timezone +from typing import Final + + +from litellm.router_utils.access_windows import ( + access_windows_config_error, + filter_reserved_deployments, + is_window_active, +) +from litellm.types.router import ModelAccessWindow + +_NIGHT_NY: Final = ModelAccessWindow( + start=time(22, 0), + end=time(6, 0), + timezone="America/New_York", + team_ids=("team-nightly",), +) + + +def _window(start: str, end: str, tz: str = "UTC", team_ids=("team-a",)) -> ModelAccessWindow: + return ModelAccessWindow( + start=time.fromisoformat(start), + end=time.fromisoformat(end), + timezone=tz, + team_ids=tuple(team_ids), + ) + + +def _deployment(windows: object = None) -> dict: + if windows is None: + return {"model_info": {}} + return {"model_info": {"access_windows": windows}} + + +def test_same_day_window_active_and_inactive(): + window: Final = _window("09:00", "17:00") + assert is_window_active(window, datetime(2026, 3, 9, 12, 0, tzinfo=timezone.utc)) is True + assert is_window_active(window, datetime(2026, 3, 9, 20, 0, tzinfo=timezone.utc)) is False + + +def test_cross_midnight_window(): + window: Final = _window("22:00", "06:00") + assert is_window_active(window, datetime(2026, 3, 9, 23, 0, tzinfo=timezone.utc)) is True + assert is_window_active(window, datetime(2026, 3, 10, 5, 59, tzinfo=timezone.utc)) is True + assert is_window_active(window, datetime(2026, 3, 9, 12, 0, tzinfo=timezone.utc)) is False + + +def test_start_boundary_inclusive_and_end_boundary_exclusive(): + window: Final = _window("22:00", "06:00") + assert is_window_active(window, datetime(2026, 3, 9, 22, 0, tzinfo=timezone.utc)) is True + assert is_window_active(window, datetime(2026, 3, 10, 6, 0, tzinfo=timezone.utc)) is False + + +def test_dst_spring_forward_gap_uses_real_local_time(): + window: Final = ModelAccessWindow( + start=time(1, 30), + end=time(3, 30), + timezone="America/New_York", + team_ids=("team-a",), + ) + assert is_window_active(window, datetime(2026, 3, 8, 6, 30, tzinfo=timezone.utc)) is True + assert is_window_active(window, datetime(2026, 3, 8, 7, 0, tzinfo=timezone.utc)) is True + assert is_window_active(window, datetime(2026, 3, 8, 7, 30, tzinfo=timezone.utc)) is False + + +def test_naive_now_is_treated_as_utc(): + window: Final = _window("09:00", "17:00") + assert is_window_active(window, datetime(2026, 3, 9, 12, 0)) is True + + +def test_team_in_second_window_is_kept(): + deployments: Final = ( + _deployment([ + {"start": "01:00", "end": "02:00", "timezone": "UTC", "team_ids": ["team-other"]}, + {"start": "20:00", "end": "23:59", "timezone": "UTC", "team_ids": ["team-a"]}, + ]), + ) + result: Final = filter_reserved_deployments( + deployments, "team-a", now=datetime(2026, 3, 9, 21, 0, tzinfo=timezone.utc) + ) + assert result.deployments == deployments + assert result.blocking_window is None + + +def test_unlisted_team_is_dropped_with_blocking_window(): + deployments: Final = (_deployment([_NIGHT_NY.model_dump()]),) + result: Final = filter_reserved_deployments( + deployments, "team-b", now=datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc) + ) + assert result.deployments == () + assert result.blocking_window == _NIGHT_NY + + +def test_missing_team_id_is_dropped(): + result: Final = filter_reserved_deployments( + (_deployment([_NIGHT_NY.model_dump()]),), + None, + now=datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc), + ) + assert result.deployments == () + assert result.blocking_window == _NIGHT_NY + + +def test_listed_team_is_kept(): + deployments: Final = (_deployment([_NIGHT_NY.model_dump()]),) + result: Final = filter_reserved_deployments( + deployments, "team-nightly", now=datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc) + ) + assert result.deployments == deployments + assert result.blocking_window is None + + +def test_deployment_without_windows_kept_for_anyone(): + deployments: Final = (_deployment(),) + result: Final = filter_reserved_deployments( + deployments, None, now=datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc) + ) + assert result.deployments == deployments + assert result.blocking_window is None + + +def test_inactive_window_keeps_deployment_for_unlisted_team(): + deployments: Final = (_deployment([_NIGHT_NY.model_dump()]),) + result: Final = filter_reserved_deployments( + deployments, "team-b", now=datetime(2026, 3, 10, 16, 0, tzinfo=timezone.utc) + ) + assert result.deployments == deployments + assert result.blocking_window is None + + +def test_unreserved_deployment_survives_for_other_team(): + reserved: Final = _deployment([_NIGHT_NY.model_dump()]) + open_deployment: Final = _deployment() + result: Final = filter_reserved_deployments( + (reserved, open_deployment), + "team-b", + now=datetime(2026, 3, 10, 4, 0, tzinfo=timezone.utc), + ) + assert result.deployments == (open_deployment,) + assert result.blocking_window == _NIGHT_NY + + +def test_config_error_unknown_timezone(): + error: Final = access_windows_config_error( + {"access_windows": [{"start": "22:00", "end": "06:00", "timezone": "Mars/Olympus", "team_ids": ["t"]}]}, + model_name="nightly-model", + ) + assert error is not None + assert "nightly-model" in error + assert "access_windows" in error + assert "Mars/Olympus" in error + + +def test_config_error_bad_time(): + error: Final = access_windows_config_error( + {"access_windows": [{"start": "25:00", "end": "06:00", "timezone": "UTC", "team_ids": ["t"]}]}, + model_name="m", + ) + assert error is not None + assert "access_windows" in error + + +def test_config_error_empty_team_ids(): + error: Final = access_windows_config_error( + {"access_windows": [{"start": "22:00", "end": "06:00", "timezone": "UTC", "team_ids": []}]}, + model_name="m", + ) + assert error is not None + + +def test_config_error_start_equals_end(): + error: Final = access_windows_config_error( + {"access_windows": [{"start": "22:00", "end": "22:00", "timezone": "UTC", "team_ids": ["t"]}]}, + model_name="m", + ) + assert error is not None + + +def test_config_error_none_when_absent(): + assert access_windows_config_error({}, model_name="m") is None + assert access_windows_config_error({"access_windows": None}, model_name="m") is None + + +def test_config_error_offset_aware_time(): + error: Final = access_windows_config_error( + {"access_windows": [{"start": "22:00+05:00", "end": "06:00", "timezone": "UTC", "team_ids": ["t"]}]}, + model_name="m", + ) + assert error is not None + assert "UTC offset" in error diff --git a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py index adee44aa8a3..4617839c5e3 100644 --- a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py +++ b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py @@ -418,6 +418,25 @@ class TestGpt6AstraAdvertisesItsDocumentedLevels: ) +class TestGpt6SolAndLunaAdvertiseNoneThroughMax: + @pytest.mark.parametrize("model", ["gpt-6-sol", "gpt-6-luna"]) + def test_the_entry_advertises_none_through_max(self, local_model_cost_map, model): + """OpenAI documents none, low, medium (default), high, xhigh and max for both. Unlike + gpt-6-astra they take none.""" + from litellm.utils import _get_model_info_helper + + model_info = dict(_get_model_info_helper(model=model, custom_llm_provider="openai")) + + assert resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True) == ( + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ) + + class TestNearestDeclaredReasoningEffort: def test_a_declared_level_is_kept(self): assert nearest_declared_reasoning_effort("high", ("none", "high")) == "high" diff --git a/tests/test_litellm/rust_bridge/test_catalog.py b/tests/test_litellm/rust_bridge/test_catalog.py index 82e3766e8a2..b6b486e73f0 100644 --- a/tests/test_litellm/rust_bridge/test_catalog.py +++ b/tests/test_litellm/rust_bridge/test_catalog.py @@ -53,10 +53,6 @@ def test_shipped_decisions( enabled: Final = environment == "1" if environment is not None else process is not False assert catalog.rollout(context) is Rollout.RUST_OPT_OUT assert catalog.decision(context) is (Decision.RUST_WITH_FALLBACK if enabled else Decision.PYTHON) - elif route in (Route.MESSAGES, Route.TOKEN_COUNTER, Route.TOKENIZER): - enabled: Final = environment == "1" if environment is not None else process is True - assert catalog.rollout(context) is Rollout.RUST_OPT_IN - assert catalog.decision(context) is (Decision.RUST_WITH_FALLBACK if enabled else Decision.PYTHON) elif route is Route.TRANSCRIPTION and provider == "bedrock": assert catalog.rollout(context) is Rollout.RUST_REQUIRED assert catalog.decision(context) is Decision.RUST_REQUIRED diff --git a/tests/test_litellm/rust_bridge/test_token_counter.py b/tests/test_litellm/rust_bridge/test_token_counter.py index 3da291c898d..3af7127a9b3 100644 --- a/tests/test_litellm/rust_bridge/test_token_counter.py +++ b/tests/test_litellm/rust_bridge/test_token_counter.py @@ -1,8 +1,7 @@ -"""Tests for the Rust input token counter bridge. +"""Tests for the Rust input token counter bridge, called directly rather than through the route catalog. -The native factory is dependency-injected through ``TOKEN_COUNTER.override`` -so the fallback cases run without the compiled extension present. The parity -cases need the extension and are skipped when it is not built. +The factory is passed into ``native_count`` so the caching cases run without the compiled extension +present. The parity cases need the extension and are skipped when it is not built. """ from __future__ import annotations @@ -16,8 +15,7 @@ import pytest import litellm from litellm.constants import TIKTOKEN_ENCODE_CHUNK_SIZE_CHARS from litellm.litellm_core_utils.token_counter import openai_tokenizer_encoding -from litellm.proxy.spend_tracking.input_tokens import count_input_tokens, count_input_tokens_for_model -from litellm.rust_bridge import bindings, configuration +from litellm.proxy.spend_tracking.input_tokens import count_input_tokens_for_model from litellm.rust_bridge import token_counter as bridge from litellm.rust_bridge import tokenizer as tokenizer_dispatch from litellm.rust_bridge._native import Tokenizer @@ -38,14 +36,6 @@ def _counted(body: dict[str, object], model: str) -> tuple[bytes, dict[str, obje return raw, json.loads(raw) -class _FakeDeclined(Exception): - pass - - -class _FakeUpstream(Exception): - pass - - class _FakeTokenizer: """Stands in for one shared native `Tokenizer`; only its name identifies it.""" @@ -54,29 +44,6 @@ class _FakeTokenizer: self.json = json -def _fake_native_tokenizers(monkeypatch: pytest.MonkeyPatch, anthropic_json: str | None = None) -> None: - """Point the counter's tokenizer lookups at fakes while the bridge is faked; the codec path - keeps falling back to Python. Parity tests that restore the real extension get the real - lookups back.""" - fakes: Final = {name: _FakeTokenizer(name) for name in ("cl100k_base", "o200k_base")} - anthropic: Final = _FakeTokenizer("anthropic", anthropic_json) - real_encoding: Final = tokenizer_dispatch.native_encoding - real_anthropic: Final = tokenizer_dispatch.native_anthropic - - def faked() -> bool: - return isinstance(bindings.get_native_bridge(), _FakeNative) - - monkeypatch.setattr( - tokenizer_dispatch, "native_encoding", lambda name: fakes[name] if faked() else real_encoding(name) - ) - monkeypatch.setattr(tokenizer_dispatch, "native_anthropic", lambda: anthropic if faked() else real_anthropic()) - - -class _FakeNative: - RustBridgeDeclined = _FakeDeclined - RustUpstreamError = _FakeUpstream - - class _RecordingCounter: def __init__(self, tokenizer: _FakeTokenizer, fast: bool) -> None: self.tokenizer = tokenizer @@ -100,57 +67,25 @@ class _RecordingFactory: return counter -class _RaisingCounter: - def __init__(self, error: Exception) -> None: - self.error = error - - async def acount_request(self, body: bytes) -> object: - raise self.error - - -class _RaisingFactory: - """Every counter it builds, for either tokenizer, raises `error` on count.""" - - def __init__(self, error: Exception) -> None: - self.error = error - - def from_tokenizer(self, tokenizer: _FakeTokenizer, fast: bool = False) -> _RaisingCounter: - return _RaisingCounter(self.error) - - @pytest.fixture(autouse=True) -def _reset_bridge(monkeypatch: pytest.MonkeyPatch): - bridge.TOKEN_COUNTER.reset() +def _reset_counters(): bridge._counter.cache_clear() - configuration.reset_rust_configuration() - monkeypatch.setattr(bindings, "get_native_bridge", lambda: _FakeNative()) - _fake_native_tokenizers(monkeypatch, anthropic_json=claude_json_str) yield - bridge.TOKEN_COUNTER.reset() bridge._counter.cache_clear() - configuration.reset_rust_configuration() + + +@pytest.fixture +def fake_tokenizers(monkeypatch: pytest.MonkeyPatch) -> None: + """Point the counter's tokenizer lookups at fakes so a recording factory sees which one it was built over.""" + fakes: Final = {name: _FakeTokenizer(name) for name in ("cl100k_base", "o200k_base")} + anthropic: Final = _FakeTokenizer("anthropic", claude_json_str) + monkeypatch.setattr(tokenizer_dispatch, "native_encoding", fakes.__getitem__) + monkeypatch.setattr(tokenizer_dispatch, "native_anthropic", lambda: anthropic) @pytest.mark.asyncio -@pytest.mark.parametrize("tokenizer", TOKENIZERS) -async def test_disabled_bridge_never_constructs_a_counter(tokenizer: bridge.RustTokenizer) -> None: +async def test_native_count_returns_typed_count_and_reuses_one_counter(fake_tokenizers: None) -> None: factory: Final = _RecordingFactory() - litellm.rust(False) - bridge.TOKEN_COUNTER.override(factory) - model: Final = MODEL_BY_TOKENIZER[tokenizer] - raw, request_body = _counted({"messages": [{"role": "user", "content": "hello"}]}, model) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=raw, models=(model,)) - - assert counts[model] == count_input_tokens_for_model(request_body=request_body, model=model) - assert factory.counters == [] - - -@pytest.mark.asyncio -async def test_enabled_bridge_returns_typed_count_and_reuses_one_counter() -> None: - factory: Final = _RecordingFactory() - litellm.rust(True) - bridge.TOKEN_COUNTER.override(factory) first: Final = await bridge.native_count(factory, "anthropic", BODY) second: Final = await bridge.native_count(factory, "anthropic", BODY) @@ -166,10 +101,10 @@ async def test_enabled_bridge_returns_typed_count_and_reuses_one_counter() -> No @pytest.mark.asyncio @pytest.mark.parametrize("tokenizer", ("cl100k_base", "o200k_base")) -async def test_tiktoken_counter_is_built_over_the_shared_encoding_once(tokenizer: bridge.RustTokenizer) -> None: +async def test_tiktoken_counter_is_built_over_the_shared_encoding_once( + fake_tokenizers: None, tokenizer: bridge.RustTokenizer +) -> None: factory: Final = _RecordingFactory() - litellm.rust(True) - bridge.TOKEN_COUNTER.override(factory) first: Final = await bridge.native_count(factory, tokenizer, BODY) second: Final = await bridge.native_count(factory, tokenizer, BODY) @@ -183,10 +118,8 @@ async def test_tiktoken_counter_is_built_over_the_shared_encoding_once(tokenizer @pytest.mark.asyncio -async def test_each_tokenizer_gets_its_own_cached_counter() -> None: +async def test_each_tokenizer_gets_its_own_cached_counter(fake_tokenizers: None) -> None: factory: Final = _RecordingFactory() - litellm.rust(True) - bridge.TOKEN_COUNTER.override(factory) await bridge.native_count(factory, "anthropic", BODY) await bridge.native_count(factory, "cl100k_base", BODY) @@ -198,43 +131,6 @@ async def test_each_tokenizer_gets_its_own_cached_counter() -> None: assert [len(counter.bodies) for counter in factory.counters] == [2, 1, 2] -@pytest.mark.asyncio -async def test_missing_native_module_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: - litellm.rust(True) - monkeypatch.setattr(bindings, "get_native_bridge", lambda: None) - raw, request_body = _counted({"messages": [{"role": "user", "content": "hello"}]}, MODEL) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=raw, models=(MODEL,)) - - assert counts[MODEL] == count_input_tokens_for_model(request_body=request_body, model=MODEL) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("tokenizer", TOKENIZERS) -async def test_declined_request_falls_back(tokenizer: bridge.RustTokenizer) -> None: - litellm.rust(True) - bridge.TOKEN_COUNTER.override(_RaisingFactory(_FakeDeclined("request has no messages"))) - model: Final = MODEL_BY_TOKENIZER[tokenizer] - raw, request_body = _counted({"messages": [{"role": "user", "content": "hello"}]}, model) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=raw, models=(model,)) - - assert counts[model] == count_input_tokens_for_model(request_body=request_body, model=model) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("tokenizer", TOKENIZERS) -async def test_runtime_failure_falls_back(tokenizer: bridge.RustTokenizer) -> None: - litellm.rust(True) - bridge.TOKEN_COUNTER.override(_RaisingFactory(RuntimeError("encode failed"))) - model: Final = MODEL_BY_TOKENIZER[tokenizer] - raw, request_body = _counted({"messages": [{"role": "user", "content": "hello"}]}, model) - - counts: Final = await count_input_tokens(request_body=request_body, raw_body=raw, models=(model,)) - - assert counts[model] == count_input_tokens_for_model(request_body=request_body, model=model) - - @pytest.mark.parametrize( ("model", "expected"), ( @@ -252,7 +148,6 @@ async def test_runtime_failure_falls_back(tokenizer: bridge.RustTokenizer) -> No ("gpt-4o", "o200k_base"), ("gpt-4o-mini", "o200k_base"), ("gpt-4o-2024-08-06", "o200k_base"), - ("chatgpt-4o-latest", "o200k_base"), ("gpt-4.1", "o200k_base"), ("gpt-5", "o200k_base"), ("gpt-5-mini", "o200k_base"), @@ -416,39 +311,34 @@ PARITY_MODELS: Final[tuple[tuple[str, bridge.RustTokenizer], ...]] = ( @pytest.mark.parametrize(("model", "tokenizer"), PARITY_MODELS) @pytest.mark.parametrize("request_body", PARITY_REQUESTS) async def test_native_count_matches_python_budget_counter( - monkeypatch: pytest.MonkeyPatch, request_body: dict[str, object], model: str, tokenizer: bridge.RustTokenizer + request_body: dict[str, object], model: str, tokenizer: bridge.RustTokenizer ) -> None: native: Final = pytest.importorskip("litellm.rust_bridge._native") - monkeypatch.setattr(bindings, "get_native_bridge", lambda: native) - litellm.rust(True) body: Final = json.dumps(request_body).replace(MODEL, model) + parsed: Final = json.loads(body) - request_body_parsed: Final = json.loads(body) - counts: Final = await count_input_tokens(request_body=request_body_parsed, raw_body=body.encode(), models=(model,)) - python_count: Final = count_input_tokens_for_model(request_body=request_body_parsed, model=model) + counted: Final = await bridge.native_count(native.TokenCounter, tokenizer, body.encode()) - assert counts[model] == python_count + assert counted.input_tokens == count_input_tokens_for_model(request_body=parsed, model=model) @pytest.mark.asyncio @pytest.mark.parametrize(("model", "tokenizer"), ((CL100K_MODEL, "cl100k_base"), (O200K_MODEL, "o200k_base"))) async def test_tiktoken_counts_long_text_exactly_where_python_chunks( - monkeypatch: pytest.MonkeyPatch, model: str, tokenizer: bridge.RustTokenizer + model: str, tokenizer: bridge.RustTokenizer ) -> None: """Python encodes tiktoken text in fixed-size chunks (drift of up to one token per chunk boundary); Rust does not.""" native: Final = pytest.importorskip("litellm.rust_bridge._native") - monkeypatch.setattr(bindings, "get_native_bridge", lambda: native) - litellm.rust(True) text: Final = "x " * 20_000 body: Final = {"model": model, "messages": [{"role": "user", "content": text}]} encoding: Final = Tokenizer.from_tiktoken(tokenizer) exact: Final = 3 + encoding.count("user") + encoding.count(text) + 3 chunks: Final = -(-len(text) // TIKTOKEN_ENCODE_CHUNK_SIZE_CHARS) - counts: Final = await count_input_tokens(request_body=body, raw_body=json.dumps(body).encode(), models=(model,)) + counted: Final = await bridge.native_count(native.TokenCounter, tokenizer, json.dumps(body).encode()) python_count: Final = count_input_tokens_for_model(request_body=body, model=model) - assert counts[model] == exact + assert counted.input_tokens == exact assert python_count is not None assert exact < python_count <= exact + chunks @@ -470,14 +360,10 @@ DECLINED_REQUESTS: Final[tuple[dict[str, object], ...]] = ( @pytest.mark.parametrize("tokenizer", TOKENIZERS) @pytest.mark.parametrize("request_body", DECLINED_REQUESTS) async def test_native_declines_shapes_python_prices_differently( - monkeypatch: pytest.MonkeyPatch, request_body: dict[str, object], tokenizer: bridge.RustTokenizer + request_body: dict[str, object], tokenizer: bridge.RustTokenizer ) -> None: native: Final = pytest.importorskip("litellm.rust_bridge._native") - monkeypatch.setattr(bindings, "get_native_bridge", lambda: native) - litellm.rust(True) - model: Final = MODEL_BY_TOKENIZER[tokenizer] - raw, parsed = _counted(request_body, model) + raw, _ = _counted(request_body, MODEL_BY_TOKENIZER[tokenizer]) - counts: Final = await count_input_tokens(request_body=parsed, raw_body=raw, models=(model,)) - - assert counts.get(model) == count_input_tokens_for_model(request_body=parsed, model=model) + with pytest.raises(native.RustBridgeDeclined): + await bridge.native_count(native.TokenCounter, tokenizer, raw) diff --git a/tests/test_litellm/rust_bridge/test_tokenizer.py b/tests/test_litellm/rust_bridge/test_tokenizer.py index 0de7ad50b1e..188aa81093f 100644 --- a/tests/test_litellm/rust_bridge/test_tokenizer.py +++ b/tests/test_litellm/rust_bridge/test_tokenizer.py @@ -1,134 +1,49 @@ -from collections.abc import Generator from typing import Final import pytest import tiktoken from tokenizers import Tokenizer -import litellm from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer, OpenAIEncoding -from litellm.rust_bridge import configuration, tokenizer -from litellm.utils import _select_tokenizer +from litellm.rust_bridge import tokenizer +from litellm.utils import claude_json_str from tests.test_litellm.litellm_core_utils.test_decode_special_tokens import TOKENIZER_JSON - -@pytest.fixture(autouse=True) -def isolated_configuration(monkeypatch: pytest.MonkeyPatch) -> Generator[None]: - monkeypatch.delenv("LITELLM_RUST", raising=False) - configuration.reset_rust_configuration() - yield - tokenizer.TOKENIZER.reset() - configuration.reset_rust_configuration() +TEXTS: Final = ("hello <|endoftext|> world", "café 漢字 🙂", " def f():\n return 1\n", "hello again") -@pytest.mark.parametrize("environment", (None, "0", "1")) -@pytest.mark.parametrize("process", (None, False, True)) -def test_tokenizer_factories_follow_rollout( - monkeypatch: pytest.MonkeyPatch, environment: str | None, process: bool | None -) -> None: - configuration.rust(process) - if environment is not None: - monkeypatch.setenv("LITELLM_RUST", environment) - enabled: Final = environment == "1" if environment is not None else process is True - encoding: Final = tokenizer.get_encoding("cl100k_base") - custom: Final = litellm.create_tokenizer(TOKENIZER_JSON) +@pytest.mark.parametrize("name", ("cl100k_base", "o200k_base")) +@pytest.mark.parametrize("text", TEXTS) +def test_native_encoding_matches_tiktoken(name: str, text: str) -> None: + native: Final = tokenizer.native_encoding(name) + if native is None: + pytest.skip("native extension is not built") + encoding: Final = OpenAIEncoding.wrap(native) + reference: Final = tiktoken.get_encoding(name) + + ids: Final = encoding.encode(text, disallowed_special=()) + assert ids == reference.encode(text, disallowed_special=()) + assert encoding.decode(ids) == reference.decode(ids) + + +@pytest.mark.parametrize("text", TEXTS) +def test_native_anthropic_tokenizer_matches_python(text: str) -> None: + native: Final = tokenizer.native_anthropic() + if native is None: + pytest.skip("native extension is not built") + reference: Final = Tokenizer.from_str(claude_json_str) + + ids: Final = HuggingFaceTokenizer(native).encode(text).ids + assert ids == reference.encode(text).ids + assert HuggingFaceTokenizer(native).decode(ids) == reference.decode(ids) + + +def test_native_custom_tokenizer_matches_python() -> None: + factory: Final = tokenizer.TOKENIZER.load() + if factory is None: + pytest.skip("native extension is not built") + native: Final = HuggingFaceTokenizer(factory.from_json(TOKENIZER_JSON)) reference: Final = Tokenizer.from_str(TOKENIZER_JSON) - assert isinstance(encoding, OpenAIEncoding if enabled else tiktoken.Encoding) - assert isinstance(custom["tokenizer"], HuggingFaceTokenizer if enabled else Tokenizer) - assert encoding.encode("café 漢字 🙂") == tiktoken.get_encoding(encoding.name).encode("café 漢字 🙂") - assert litellm.encode(text="Hello World", custom_tokenizer=custom) == reference.encode("Hello World").ids - assert litellm.token_counter(text="Hello World", custom_tokenizer=custom) == len(reference.encode("Hello World")) - - -def test_missing_native_binding_keeps_python_tokenizer_api() -> None: - configuration.rust(True) - tokenizer.TOKENIZER.override(None) - encoding: Final = tokenizer.get_encoding("cl100k_base") - custom: Final = litellm.create_tokenizer(TOKENIZER_JSON)["tokenizer"] - - assert isinstance(encoding, tiktoken.Encoding) - assert isinstance(custom, Tokenizer) - custom.enable_padding(pad_id=0, pad_token="[UNK]") - assert [item.ids for item in custom.encode_batch(["Hello", "Hello World"])] == [[3, 1, 0], [3, 1, 2]] - - -def test_cached_selection_follows_backend_changes(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(litellm, "disable_hf_tokenizer_download", True) - configuration.rust(True) - native: Final = _select_tokenizer("dispatch-fixture")["tokenizer"] - configuration.rust(False) - python: Final = _select_tokenizer("dispatch-fixture")["tokenizer"] - - assert isinstance(native, OpenAIEncoding) - assert isinstance(python, tiktoken.Encoding) - assert native.encode("hello") == python.encode("hello") - - -def test_declined_native_factory_falls_back_before_tokenizing() -> None: - from litellm.rust_bridge._native import RustBridgeDeclined - - class UnavailableTokenizer: - @staticmethod - def from_json(json: str) -> None: - raise RustBridgeDeclined("huggingface feature is disabled") - - configuration.rust(True) - binding: Final = tokenizer._as_factory(UnavailableTokenizer) - tokenizer.TOKENIZER.override(binding) - custom: Final = litellm.create_tokenizer(TOKENIZER_JSON) - - assert isinstance(custom["tokenizer"], Tokenizer) - assert ( - litellm.decode(tokens=litellm.encode(text="Hello World", custom_tokenizer=custom), custom_tokenizer=custom) - == "Hello World" - ) - - -@pytest.mark.parametrize( - ("model", "text"), - ( - ("gpt-4o", "hello <|endoftext|> world"), - ("gpt-3.5-turbo", "café 漢字 🙂"), - ("text-davinci-003", " def f():\n return 1\n"), - ("tokenizer-parity-fixture", "hello again"), - ), -) -def test_public_token_api_is_identical_across_backends(monkeypatch: pytest.MonkeyPatch, model: str, text: str) -> None: - """`litellm.token_counter`, `encode` and `decode` return the same values whichever backend - the catalog picks; only the object types differ.""" - monkeypatch.setattr(litellm, "anthropic_models", {*litellm.anthropic_models, "tokenizer-parity-fixture"}) - messages: Final = [{"role": "user", "content": text}, {"role": "assistant", "content": "ok"}] - - def observe() -> tuple[int, int, list[int], str]: - ids: Final = litellm.encode(model=model, text=text) - return ( - litellm.token_counter(model=model, text=text), - litellm.token_counter(model=model, messages=messages), - ids, - litellm.decode(model=model, tokens=ids), - ) - - configuration.rust(False) - python: Final = observe() - configuration.rust(True) - rust: Final = observe() - - assert rust == python - - -def test_cached_huggingface_tokenizers_follow_backend_changes(monkeypatch: pytest.MonkeyPatch) -> None: - from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer as RustHuggingFaceTokenizer - from litellm.utils import _load_huggingface_tokenizer - - monkeypatch.setattr(litellm, "anthropic_models", {*litellm.anthropic_models, "tokenizer-cache-fixture"}) - _load_huggingface_tokenizer.cache_clear() - configuration.rust(True) - native: Final = _select_tokenizer("tokenizer-cache-fixture")["tokenizer"] - configuration.rust(False) - python: Final = _select_tokenizer("tokenizer-cache-fixture")["tokenizer"] - configuration.rust(True) - - assert isinstance(native, RustHuggingFaceTokenizer) - assert isinstance(python, Tokenizer) - assert _select_tokenizer("tokenizer-cache-fixture")["tokenizer"] is native + assert native.encode("Hello World").ids == reference.encode("Hello World").ids + assert native.decode(reference.encode("Hello World").ids) == reference.decode(reference.encode("Hello World").ids) diff --git a/tests/test_litellm/secret_managers/test_aws_secret_manager_rotation.py b/tests/test_litellm/secret_managers/test_aws_secret_manager_rotation.py index 4a4cec6bf77..af1380f6fd1 100644 --- a/tests/test_litellm/secret_managers/test_aws_secret_manager_rotation.py +++ b/tests/test_litellm/secret_managers/test_aws_secret_manager_rotation.py @@ -1,11 +1,17 @@ -from collections.abc import Mapping +import json +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass, replace from types import MappingProxyType from typing import Final, TypeAlias +import httpx import pytest +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.secret_managers.aws_secret_manager_v2 import AWSSecretsManagerV2 +from litellm.types.llms.custom_http import httpxSpecialProvider OptionalParams: TypeAlias = Mapping[str, object] | None @@ -204,3 +210,173 @@ async def test_rotate_secret_different_names_persists_requested_value_and_delete assert manager.storage.values[new_name] == new_value assert current_name not in manager.storage.values assert manager.storage.values[unrelated_secret_name] == unrelated_value + + +@dataclass(frozen=True, slots=True) +class FakeSecretsManagerState: + live: Mapping[str, str] + scheduled_for_deletion: frozenset[str] = frozenset() + actions: tuple[str, ...] = () + descriptions: Mapping[str, str] = MappingProxyType({}) + failing_actions: frozenset[str] = frozenset() + + +class FakeSecretsManagerService: + def __init__(self, state: FakeSecretsManagerState) -> None: + self.state = state + + def handle(self, request: httpx.Request) -> httpx.Response: + action: Final = request.headers["X-Amz-Target"].removeprefix("secretsmanager.") + body: Final = json.loads(request.content) + name: Final = str(body.get("Name") or body.get("SecretId")) + self.state = replace(self.state, actions=(*self.state.actions, f"{action}:{name}")) + if action in self.state.failing_actions: + return self._error("InternalServiceError", f"injected failure for {action}") + match action: + case "CreateSecret": + if name in self.state.live: + return self._error("ResourceExistsException", f"The secret {name} already exists") + self.state = replace( + self.state, + live=MappingProxyType({**self.state.live, name: str(body["SecretString"])}), + descriptions=MappingProxyType({**self.state.descriptions, name: str(body.get("Description", ""))}), + ) + return httpx.Response(200, json={"ARN": f"arn:fake:{name}", "Name": name}) + case "UpdateSecret": + if name in self.state.scheduled_for_deletion: + return self._error( + "InvalidRequestException", + "You can't perform this operation on the secret because it was marked for deletion.", + ) + self.state = replace( + self.state, + live=MappingProxyType({**self.state.live, name: str(body["SecretString"])}), + descriptions=MappingProxyType({**self.state.descriptions, name: str(body.get("Description", ""))}), + ) + return httpx.Response(200, json={"ARN": f"arn:fake:{name}", "Name": name}) + case "DescribeSecret": + if name not in self.state.live: + return self._error("ResourceNotFoundException", "Secrets Manager can't find the specified secret.") + deleted: Final = "2026-01-01T00:00:00Z" if name in self.state.scheduled_for_deletion else None + return httpx.Response(200, json={"ARN": f"arn:fake:{name}", "Name": name, "DeletedDate": deleted}) + case "RestoreSecret": + self.state = replace(self.state, scheduled_for_deletion=self.state.scheduled_for_deletion - {name}) + return httpx.Response(200, json={"ARN": f"arn:fake:{name}", "Name": name}) + case "PutSecretValue": + if name in self.state.scheduled_for_deletion: + return self._error( + "InvalidRequestException", + "You can't perform this operation on the secret because it was marked for deletion.", + ) + self.state = replace( + self.state, live=MappingProxyType({**self.state.live, name: str(body["SecretString"])}) + ) + return httpx.Response(200, json={"ARN": f"arn:fake:{name}", "Name": name}) + case "GetSecretValue": + if name not in self.state.live or name in self.state.scheduled_for_deletion: + return self._error("ResourceNotFoundException", "Secrets Manager can't find the specified secret.") + return httpx.Response(200, json={"SecretString": self.state.live[name]}) + case "DeleteSecret": + self.state = replace(self.state, scheduled_for_deletion=self.state.scheduled_for_deletion | {name}) + return httpx.Response(200, json={"ARN": f"arn:fake:{name}", "Name": name}) + return self._error("UnsupportedAction", action) + + @staticmethod + def _error(error_type: str, message: str) -> httpx.Response: + return httpx.Response(400, json={"__type": error_type, "message": message}) + + +@contextmanager +def fake_secrets_manager(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeSecretsManagerService]: + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "synthetic-access-key") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "synthetic-secret-key") + monkeypatch.setenv("AWS_REGION_NAME", "us-east-1") + service: Final = FakeSecretsManagerService(FakeSecretsManagerState(live=MappingProxyType({}))) + cache_key: Final = "async_httpx_clienttimeout_None" + httpxSpecialProvider.SecretManager + litellm.in_memory_llm_clients_cache.set_cache( + key=cache_key, + value=AsyncHTTPHandler(transport=httpx.MockTransport(service.handle)), + ) + try: + yield service + finally: + litellm.in_memory_llm_clients_cache.delete_cache( + litellm.in_memory_llm_clients_cache.update_cache_key_with_event_loop(cache_key) + ) + + +@pytest.mark.asyncio +async def test_rotate_secret_back_to_name_inside_recovery_window_restores_and_stores_new_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + alias_a: Final = "synthetic/alias-a" + alias_b: Final = "synthetic/alias-b" + with fake_secrets_manager(monkeypatch) as fake: + manager: Final = AWSSecretsManagerV2(aws_region_name="us-east-1") + await manager.async_write_secret(secret_name=alias_a, secret_value="value-1") + await manager.async_rotate_secret( + current_secret_name=alias_a, new_secret_name=alias_b, new_secret_value="value-2" + ) + assert alias_a in fake.state.scheduled_for_deletion + + await manager.async_rotate_secret( + current_secret_name=alias_b, new_secret_name=alias_a, new_secret_value="value-3" + ) + + assert await manager.async_read_secret(secret_name=alias_a) == "value-3" + assert await manager.async_read_secret(secret_name=alias_b) is None + assert fake.state.scheduled_for_deletion == frozenset({alias_b}) + assert fake.state.descriptions[alias_a] == f"Rotated from {alias_b}" + + +@pytest.mark.asyncio +async def test_write_secret_to_name_inside_recovery_window_reschedules_deletion_when_update_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + alias: Final = "synthetic/deleted-alias" + with fake_secrets_manager(monkeypatch) as fake: + manager: Final = AWSSecretsManagerV2(aws_region_name="us-east-1") + await manager.async_write_secret(secret_name=alias, secret_value="value-1") + await manager.async_delete_secret(secret_name=alias, recovery_window_in_days=7) + fake.state = replace(fake.state, failing_actions=frozenset({"UpdateSecret"})) + + with pytest.raises(ValueError, match="injected failure for UpdateSecret"): + await manager.async_write_secret(secret_name=alias, secret_value="value-2") + + assert fake.state.scheduled_for_deletion == frozenset({alias}) + assert fake.state.live[alias] == "value-1" + + +@pytest.mark.asyncio +async def test_write_secret_to_name_inside_recovery_window_restores_and_stores_new_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + alias: Final = "synthetic/deleted-alias" + with fake_secrets_manager(monkeypatch) as fake: + manager: Final = AWSSecretsManagerV2(aws_region_name="us-east-1") + await manager.async_write_secret(secret_name=alias, secret_value="value-1") + await manager.async_delete_secret(secret_name=alias, recovery_window_in_days=7) + + assert await manager.async_write_secret(secret_name=alias, secret_value="value-2") == { + "ARN": f"arn:fake:{alias}", + "Name": alias, + } + + assert await manager.async_read_secret(secret_name=alias) == "value-2" + assert fake.state.scheduled_for_deletion == frozenset() + + +@pytest.mark.asyncio +async def test_write_secret_to_live_existing_name_still_fails_without_overwriting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + alias: Final = "synthetic/live-alias" + with fake_secrets_manager(monkeypatch) as fake: + manager: Final = AWSSecretsManagerV2(aws_region_name="us-east-1") + await manager.async_write_secret(secret_name=alias, secret_value="value-1") + + with pytest.raises(ValueError, match="ResourceExistsException"): + await manager.async_write_secret(secret_name=alias, secret_value="value-2") + + assert await manager.async_read_secret(secret_name=alias) == "value-1" + assert f"RestoreSecret:{alias}" not in fake.state.actions diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index a33701cd1bf..e7ce9a74797 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -1,6 +1,7 @@ import datetime import time -from types import MappingProxyType +from pathlib import Path +from types import MappingProxyType, SimpleNamespace from typing import Final, cast import pytest @@ -22,8 +23,13 @@ from litellm.types.llms.base import CachedTokensDetails from litellm.types.llms.openai import OpenAIRealtimeStreamList, ResponseAPIUsage, ResponsesAPIResponse from litellm.types.rerank import RerankResponse from litellm.types.utils import ( + CacheCreationTokenDetails, CallTypes, Choices, + ImageObject, + ImageResponse, + ImageUsage, + ImageUsageInputTokensDetails, LiteLLMRealtimeStreamLoggingObject, Message, ModelInfo, @@ -153,131 +159,8 @@ def test_cost_calculator_with_response_cost_in_additional_headers(): assert result == 1000 -def test_cost_calculator_with_usage(_local_model_cost_map, monkeypatch): - - usage = Usage( - prompt_tokens=120, - completion_tokens=100, - prompt_tokens_details=PromptTokensDetailsWrapper( - text_tokens=10, - audio_tokens=90, - image_tokens=20, - ), - ) - mr = ModelResponse(usage=usage, model="gemini-2.0-flash-001") - - result = response_cost_calculator( - response_object=mr, - model="", - custom_llm_provider="vertex_ai", - call_type="acompletion", - optional_params={}, - cache_hit=None, - base_model=None, - ) - - model_info = litellm.model_cost["gemini-2.0-flash-001"] - - # Step 1: Test a model where input_cost_per_image_token is not set. - # In this case the calculation should use input_cost_per_token as fallback. - assert model_info.get("input_cost_per_image_token") is None, ( - "Test case expects that input_cost_per_image_token is not set" - ) - - expected_cost = ( - usage.prompt_tokens_details.audio_tokens * model_info["input_cost_per_audio_token"] - + usage.prompt_tokens_details.text_tokens * model_info["input_cost_per_token"] - + usage.prompt_tokens_details.image_tokens * model_info["input_cost_per_token"] - + usage.completion_tokens * model_info["output_cost_per_token"] - ) - - assert result == expected_cost, f"Got {result}, Expected {expected_cost}" - - # Step 2: Set input_cost_per_image_token. - # In this case the explicit cost information should be used. - temp_model_info_object = dict(model_info) - temp_model_info_object["input_cost_per_image_token"] = 0.5 - - monkeypatch.setattr( - litellm, - "model_cost", - {"gemini-2.0-flash-001": temp_model_info_object}, - ) - - # Invalidate caches after modifying litellm.model_cost - from litellm.utils import _invalidate_model_cost_lowercase_map - - _invalidate_model_cost_lowercase_map() - - result = response_cost_calculator( - response_object=mr, - model="", - custom_llm_provider="vertex_ai", - call_type="acompletion", - optional_params={}, - cache_hit=None, - base_model=None, - ) - - expected_cost = ( - usage.prompt_tokens_details.audio_tokens * temp_model_info_object["input_cost_per_audio_token"] - + usage.prompt_tokens_details.text_tokens * temp_model_info_object["input_cost_per_token"] - + usage.prompt_tokens_details.image_tokens * temp_model_info_object["input_cost_per_image_token"] - + usage.completion_tokens * temp_model_info_object["output_cost_per_token"] - ) - - assert result == expected_cost, f"Got {result}, Expected {expected_cost}" -def test_handle_realtime_stream_cost_calculation_stores_cost_breakdown(): - """Regression: realtime cost must populate logging_obj.cost_breakdown so the - spend logs / UI show input vs output cost (issue: cost_breakdown was None for - /v1/realtime even though a total spend was computed).""" - from datetime import datetime - - from litellm.litellm_core_utils.litellm_logging import Logging - - results: OpenAIRealtimeStreamList = [ - {"type": "session.created", "session": {"model": "gpt-4o-realtime-preview"}}, - { - "type": "response.done", - "response": { - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "total_tokens": 150, - } - }, - }, - ] - combined_usage_object = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( - results=results, - ) - - logging_obj = Logging( - model="gpt-4o-realtime-preview", - messages=[], - stream=False, - call_type="_arealtime", - start_time=datetime.now(), - litellm_call_id="realtime-cost-breakdown-test", - function_id="realtime-cost-breakdown-test", - ) - - total_cost = handle_realtime_stream_cost_calculation( - results=results, - combined_usage_object=combined_usage_object, - custom_llm_provider="openai", - litellm_model_name="gpt-4o-realtime-preview", - litellm_logging_obj=logging_obj, - ) - - assert total_cost > 0 - assert logging_obj.cost_breakdown is not None - assert logging_obj.cost_breakdown["input_cost"] > 0 - assert logging_obj.cost_breakdown["output_cost"] > 0 - assert abs(logging_obj.cost_breakdown["input_cost"] + logging_obj.cost_breakdown["output_cost"] - total_cost) < 1e-9 - assert abs(logging_obj.cost_breakdown["total_cost"] - total_cost) < 1e-9 def test_realtime_stream_combines_text_and_audio_token_details(): @@ -686,6 +569,90 @@ def test_tiered_pricing_only_deployment_selects_router_model_id(): assert router_model_id in selected +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +def test_completion_cost_image_generation_reads_deployment_model_info_price_from_logging_metadata( + _local_model_cost_map: None, metadata_key: str +) -> None: + cost = completion_cost( + completion_response=ImageResponse(data=[ImageObject(url="https://example.com/img.png")]), + model="fal_ai/fal-ai/unlisted-image-model", + call_type="image_generation", + custom_pricing=True, + litellm_logging_obj=SimpleNamespace( + litellm_params={metadata_key: {"model_info": {"output_cost_per_image": 0.08}}} + ), + ) + + assert cost == pytest.approx(0.08) + + +def test_completion_cost_image_generation_registered_deployment_price_keeps_map_token_rates( + _local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch +) -> None: + deployment_id: Final = "gemini-image-deployment-priced-per-image" + monkeypatch.setitem( + litellm.model_cost, + deployment_id, + {"mode": "image_generation", "litellm_provider": "gemini", "output_cost_per_image": 0.1}, + ) + usage: Final = ImageUsage( + input_tokens=10, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=10), + output_tokens=1290, + total_tokens=1300, + ) + + cost = completion_cost( + completion_response=ImageResponse(data=[ImageObject(url="https://example.com/img.png")], usage=usage), + model="gemini/gemini-3.1-flash-image-preview", + custom_llm_provider="gemini", + call_type="image_generation", + custom_pricing=True, + router_model_id=deployment_id, + litellm_logging_obj=SimpleNamespace(litellm_params={"metadata": {"model_info": {"id": deployment_id}}}), + ) + + assert cost == pytest.approx(10 * 5e-07 + 1290 * 6e-05) + + +def test_completion_cost_image_generation_ignores_deployment_model_info_without_custom_pricing( + _local_model_cost_map: None, +) -> None: + cost = completion_cost( + completion_response=ImageResponse(data=[ImageObject(url="https://example.com/img.png")]), + model="fal_ai/openai/gpt-image-2", + call_type="image_generation", + custom_pricing=False, + optional_params={"quality": "high", "image_size": {"width": 1024, "height": 1024}}, + litellm_logging_obj=SimpleNamespace( + litellm_params={"litellm_metadata": {"model_info": {"output_cost_per_image": 0.5}}} + ), + ) + + assert cost == pytest.approx(0.211) + + +async def test_router_image_generation_bills_litellm_params_output_cost_per_image() -> None: + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "img", + "litellm_params": { + "model": "fal_ai/fal-ai/unlisted-image-model", + "api_key": "sk-fake", + "output_cost_per_image": 0.08, + }, + } + ] + ) + + response = await router.aimage_generation(model="img", prompt="x", mock_response="https://example.com/img.png") + + assert response._hidden_params["response_cost"] == pytest.approx(0.08) + + def test_tiered_pricing_only_deployment_completion_cost_is_nonzero(): """End-to-end: a tier-only deployment must produce the tiered cost, not $0. Mirrors the reported dashscope/qwen3.7-plus trace (12 prompt + 377 @@ -1034,126 +1001,6 @@ def test_bedrock_cost_calculator_comparison_with_without_cache(): print(f"Cost with cache: {cost_with_cache}") -def test_log_context_cost_calculation(): - """ - Test that log context cost calculation works correctly with tiered pricing. - - This test verifies that when using extended context (above 200k tokens), - the log context costs are calculated using the appropriate tiered rates. - """ - from litellm import completion_cost - from litellm.types.utils import ( - Choices, - Message, - ModelResponse, - PromptTokensDetailsWrapper, - Usage, - ) - - # Create a mock response with extended context usage - extended_context_response = ModelResponse( - id="test-extended-context-response", - created=1750733889, - model="claude-4-sonnet-20250514", - object="chat.completion", - system_fingerprint=None, - choices=[ - Choices( - finish_reason="stop", - index=0, - message=Message( - content="This is a test response for extended context cost calculation.", - role="assistant", - tool_calls=None, - function_call=None, - ), - ) - ], - usage=Usage( - total_tokens=350000, # Above 200k threshold - prompt_tokens=301000, # Above 200k threshold - completion_tokens=50000, - prompt_tokens_details=PromptTokensDetailsWrapper( - text_tokens=300000, - cached_tokens=0, # No cache hits - audio_tokens=None, - image_tokens=None, - character_count=None, - video_length_seconds=None, - cache_creation_tokens=1000, - ), - completion_tokens_details=None, - _cache_creation_input_tokens=1000, # Some tokens added to cache - ), - ) - - # Calculate the cost using the extended context model - result = completion_cost( - completion_response=extended_context_response, - model="claude-4-sonnet-20250514", - custom_llm_provider="anthropic", - ) - - # Debug: Print the actual result - print(f"DEBUG: Actual cost result: ${result:.6f}") - - # Get model info to understand the pricing - from litellm import get_model_info - - model_info = get_model_info(model="claude-4-sonnet-20250514", custom_llm_provider="anthropic") - - # Calculate expected cost based on actual model pricing - input_cost_per_token = model_info.get("input_cost_per_token", 0) - output_cost_per_token = model_info.get("output_cost_per_token", 0) - cache_creation_cost_per_token = model_info.get("cache_creation_input_token_cost", 0) - - # Check if tiered pricing is applied - input_cost_above_200k = model_info.get("input_cost_per_token_above_200k_tokens", input_cost_per_token) - output_cost_above_200k = model_info.get("output_cost_per_token_above_200k_tokens", output_cost_per_token) - cache_creation_above_200k = model_info.get( - "cache_creation_input_token_cost_above_200k_tokens", - cache_creation_cost_per_token, - ) - - print(f"DEBUG: Base input cost per token: ${input_cost_per_token:.2e}") - print(f"DEBUG: Base output cost per token: ${output_cost_per_token:.2e}") - print(f"DEBUG: Base cache creation cost per token: ${cache_creation_cost_per_token:.2e}") - - # Handle tiered pricing - if not available, use base pricing - if input_cost_above_200k is not None: - print(f"DEBUG: Tiered input cost per token (>200k): ${input_cost_above_200k:.2e}") - else: - print("DEBUG: No tiered input pricing available, using base pricing") - input_cost_above_200k = input_cost_per_token - - if output_cost_above_200k is not None: - print(f"DEBUG: Tiered output cost per token (>200k): ${output_cost_above_200k:.2e}") - else: - print("DEBUG: No tiered output pricing available, using base pricing") - output_cost_above_200k = output_cost_per_token - - if cache_creation_above_200k is not None: - print(f"DEBUG: Tiered cache creation cost per token (>200k): ${cache_creation_above_200k:.2e}") - else: - print("DEBUG: No tiered cache creation pricing available, using base pricing") - cache_creation_above_200k = cache_creation_cost_per_token - - # Since we're above 200k tokens, we should use tiered pricing if available - expected_input_cost = 300000 * input_cost_above_200k - expected_output_cost = 50000 * output_cost_above_200k - expected_cache_cost = 1000 * cache_creation_above_200k - expected_total = expected_input_cost + expected_output_cost + expected_cache_cost - - print(f"DEBUG: Expected total: ${expected_total:.6f}") - - # Allow for small floating point differences - assert abs(result - expected_total) < 1e-6, f"Expected cost ${expected_total:.6f}, but got ${result:.6f}" - - print(f"✓ Log context cost calculation with tiered pricing is correct: ${result:.6f}") - print(f" - Input tokens (300k): ${expected_input_cost:.6f}") - print(f" - Output tokens (50k): ${expected_output_cost:.6f}") - print(f" - Cache creation (1k): ${expected_cache_cost:.6f}") - print(f" - Total: ${result:.6f}") def test_gemini_25_explicit_caching_cost_direct_usage(): @@ -1724,56 +1571,6 @@ def test_cost_margin_with_discount(monkeypatch): print(f" - Expected: ${expected_cost:.6f}") -def test_azure_image_generation_cost_calculator(): - from unittest.mock import MagicMock - - from litellm.types.utils import ( - ImageObject, - ImageResponse, - ImageUsage, - ImageUsageInputTokensDetails, - ) - - response_cost_calculator_kwargs = { - "response_object": ImageResponse( - created=1761785270, - background=None, - data=[ - ImageObject( - b64_json=None, - revised_prompt="A futuristic, techno-inspired green duck wearing cool modern sunglasses. The duck has a sleek, metallic appearance with glowing neon green accents, standing on a high-tech urban background with holographic billboards and illuminated city lights in the distance. The duck's feathers have a glossy, high-tech sheen, resembling a robotic design but still maintaining its avian features. The scene has a vibrant, cyberpunk aesthetic with a neon color palette.", - url="test-azure-blob-url-with-sas-token", - ) - ], - output_format=None, - quality="hd", - size=None, - usage=ImageUsage( - input_tokens=0, - input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0), - output_tokens=0, - total_tokens=0, - ), - ), - "model": "azure/dall-e-3", - "cache_hit": False, - "custom_llm_provider": "azure", - "base_model": "azure/dall-e-3", - "call_type": "aimage_generation", - "optional_params": {}, - "custom_pricing": False, - "prompt": "", - "standard_built_in_tools_params": { - "web_search_options": None, - "file_search": None, - }, - "router_model_id": "6738c432ffc9b733597c6b86613ca20dc5f49bde591fd3d03e7cd6aa25bb241e", - "litellm_logging_obj": MagicMock(), - "service_tier": None, - } - - cost = response_cost_calculator(**response_cost_calculator_kwargs) - assert cost > 0.079 def test_completion_cost_extracts_service_tier_from_response(_local_model_cost_map): @@ -2526,87 +2323,6 @@ def test_gemini_without_cache_tokens_details(): print("✅ Gemini without cacheTokensDetails works correctly") -def test_gemini_implicit_caching_cost_calculation(): - """ - Test for Issue #16341: Gemini implicit cached tokens not counted in spend log - - When Gemini uses implicit caching, it returns cachedContentTokenCount but NOT - cacheTokensDetails. In this case, we should subtract cachedContentTokenCount - from text_tokens to correctly calculate costs. - - See: https://github.com/BerriAI/litellm/issues/16341 - """ - from litellm import completion_cost - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, - ) - from litellm.types.utils import Choices, Message, ModelResponse - - # Simulate Gemini response with implicit caching (cachedContentTokenCount only) - completion_response = { - "usageMetadata": { - "promptTokenCount": 10000, - "candidatesTokenCount": 5, - "totalTokenCount": 10005, - "cachedContentTokenCount": 8000, # Implicit caching - no cacheTokensDetails - "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 10000}], - "candidatesTokensDetails": [{"modality": "TEXT", "tokenCount": 5}], - } - } - - usage = VertexGeminiConfig._calculate_usage(completion_response) - - # Verify parsing - assert usage.cache_read_input_tokens == 8000, ( - f"cache_read_input_tokens should be 8000, got {usage.cache_read_input_tokens}" - ) - assert usage.prompt_tokens_details.cached_tokens == 8000, ( - f"cached_tokens should be 8000, got {usage.prompt_tokens_details.cached_tokens}" - ) - - # CRITICAL: text_tokens should be (10000 - 8000) = 2000, NOT 10000 - # This is the fix for issue #16341 - assert usage.prompt_tokens_details.text_tokens == 2000, ( - f"text_tokens should be 2000 (10000 - 8000), got {usage.prompt_tokens_details.text_tokens}" - ) - - # Verify cost calculation uses cached token pricing - response = ModelResponse( - id="mock-id", - model="gemini-2.0-flash", - choices=[ - Choices( - index=0, - message=Message(role="assistant", content="Hello!"), - finish_reason="stop", - ) - ], - usage=usage, - ) - - cost = completion_cost( - completion_response=response, - model="gemini-2.0-flash", - custom_llm_provider="gemini", - ) - - # Get model pricing for verification - import litellm - - model_info = litellm.get_model_info("gemini/gemini-2.0-flash") - input_cost = model_info.get("input_cost_per_token", 0) - cache_read_cost = model_info.get("cache_read_input_token_cost", input_cost) - output_cost = model_info.get("output_cost_per_token", 0) - - # Expected cost: (2000 * input) + (8000 * cache_read) + (5 * output) - expected_cost = (2000 * input_cost) + (8000 * cache_read_cost) + (5 * output_cost) - - assert abs(cost - expected_cost) < 1e-9, ( - f"Cost calculation is wrong. Got ${cost:.6f}, expected ${expected_cost:.6f}. " - f"Cached tokens may not be using reduced pricing." - ) - - print("✅ Issue #16341 fix verified: Gemini implicit caching cost calculated correctly") def test_additional_costs_only_for_azure_ai(_local_model_cost_map): @@ -4757,3 +4473,27 @@ def test_cost_per_token_bedrock_qwen3_next_uses_regional_entry_not_us_rate( assert prompt_usd == pytest.approx(prompt_tokens * regional["input_cost_per_token"]) assert completion_usd == pytest.approx(completion_tokens * regional["output_cost_per_token"]) + + +def test_cost_per_token_bedrock_nemotron_super_3_uses_eu_west_2_entry_not_us_rate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + + regional_key: Final = "bedrock/eu-west-2/nvidia.nemotron-super-3-120b" + regional: Final = litellm.model_cost[regional_key] + us: Final = litellm.model_cost["nvidia.nemotron-super-3-120b"] + assert regional["input_cost_per_token"] != us["input_cost_per_token"] + assert regional["output_cost_per_token"] != us["output_cost_per_token"] + + prompt_tokens, completion_tokens = 1000, 500 + prompt_usd, completion_usd = cost_per_token( + model=regional_key, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + custom_llm_provider="bedrock", + ) + + assert prompt_usd == pytest.approx(prompt_tokens * regional["input_cost_per_token"]) + assert completion_usd == pytest.approx(completion_tokens * regional["output_cost_per_token"]) diff --git a/tests/test_litellm/test_count_tokens_public_api.py b/tests/test_litellm/test_count_tokens_public_api.py index 2918d0aa522..84121dd63e4 100644 --- a/tests/test_litellm/test_count_tokens_public_api.py +++ b/tests/test_litellm/test_count_tokens_public_api.py @@ -157,21 +157,3 @@ def test_acount_tokens_no_api_key_falls_back(monkeypatch): assert result.tokenizer_type == "local_tokenizer" -async def test_acount_tokens_local_fallback_counts_off_the_event_loop(): - from tests.large_text import text - from tests.test_litellm.litellm_core_utils.event_loop_lag import ( - assert_loop_stayed_free, - timed_with_loop_lags, - warm_tokenizer, - ) - - model = "together_ai/meta-llama/Llama-3-8b-chat-hf" - warm_tokenizer(model) - - result, took, lags = await timed_with_loop_lags( - lambda: litellm.acount_tokens(model=model, messages=[{"role": "user", "content": text * 100}]) - ) - - assert result.tokenizer_type == "local_tokenizer" - assert result.total_tokens > 100_000 - assert_loop_stayed_free(took, lags) diff --git a/tests/test_litellm/test_gpt_image_cost_calculator.py b/tests/test_litellm/test_gpt_image_cost_calculator.py index 42d4c699200..d026285f9ae 100644 --- a/tests/test_litellm/test_gpt_image_cost_calculator.py +++ b/tests/test_litellm/test_gpt_image_cost_calculator.py @@ -16,6 +16,8 @@ import litellm from litellm.types.utils import ( ImageObject, ImageResponse, + ImageUsage, + ImageUsageInputTokensDetails, ) @@ -52,31 +54,42 @@ class TestGPTImageCostCalculator: assert cost == 0.0 + @pytest.mark.parametrize( + "usage", + [ + None, + ImageUsage( + input_tokens=0, + input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=0), + output_tokens=0, + total_tokens=0, + ), + ], + ) + def test_gpt_image_1_bills_deployment_output_cost_per_image_without_usage_tokens( + self, usage: ImageUsage | None + ) -> None: + from litellm.llms.openai.image_generation.cost_calculator import cost_calculator + + image_response = ImageResponse( + created=1234567890, + data=[ImageObject(url="http://example.com/one.jpg"), ImageObject(url="http://example.com/two.jpg")], + usage=usage, + ) + + cost = cost_calculator( + model="gpt-image-1", + image_response=image_response, + custom_llm_provider="openai", + model_info={"output_cost_per_image": 0.05}, + ) + + assert cost == pytest.approx(0.10) + class TestGPTImageCostRouting: """Test that gpt-image models are properly routed to the token-based calculator""" - def test_openai_dalle_routes_to_pixel_calculator(self): - """Test that OpenAI DALL-E still routes to pixel-based calculator""" - from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils - - image_response = ImageResponse( - created=1234567890, - data=[ImageObject(url="http://example.com/image.jpg")], - ) - image_response.size = "1024x1024" - image_response.quality = "standard" - - cost = CostCalculatorUtils.route_image_generation_cost_calculator( - model="dall-e-3", - completion_response=image_response, - custom_llm_provider="openai", - size="1024x1024", - quality="standard", - n=1, - ) - - assert cost >= 0 class TestGPTImage15OutputImageTokens: diff --git a/tests/test_litellm/test_openai_service_tier_long_context_pricing.py b/tests/test_litellm/test_openai_service_tier_long_context_pricing.py index c766370230c..9b3a1e57169 100644 --- a/tests/test_litellm/test_openai_service_tier_long_context_pricing.py +++ b/tests/test_litellm/test_openai_service_tier_long_context_pricing.py @@ -58,6 +58,18 @@ PRIORITY_LONG_CONTEXT = { "cache_read_input_token_cost_above_272k_tokens_priority": 4e-06, "cache_creation_input_token_cost_above_272k_tokens_priority": 5e-05, }, + "gpt-6-sol": { + "input_cost_per_token_above_272k_tokens_priority": 8e-06, + "output_cost_per_token_above_272k_tokens_priority": 3e-05, + "cache_read_input_token_cost_above_272k_tokens_priority": 8e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, + }, + "gpt-6-luna": { + "input_cost_per_token_above_272k_tokens_priority": 4e-07, + "output_cost_per_token_above_272k_tokens_priority": 1.5e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 4e-08, + "cache_creation_input_token_cost_above_272k_tokens_priority": 5e-07, + }, } EXPECTED = {**FLEX_LONG_CONTEXT, **PRIORITY_LONG_CONTEXT} @@ -90,4 +102,6 @@ TIERED_COST_CASES = [ ("gpt-5.6-terra", "priority", 8e-06, 3.6e-05), ("gpt-5.6-luna", "priority", 8e-07, 3.6e-06), ("gpt-6-astra", "priority", 4e-05, 0.00015), + ("gpt-6-sol", "priority", 8e-06, 3e-05), + ("gpt-6-luna", "priority", 4e-07, 1.5e-06), ] diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 0df6a181957..411377f29cf 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -9,7 +9,7 @@ import sys import threading import warnings from collections.abc import Awaitable, Callable, Mapping -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import Final, Literal from unittest.mock import AsyncMock, MagicMock, patch @@ -850,36 +850,6 @@ async def test_arouter_async_get_healthy_deployments(): assert result[0]["litellm_params"]["model"] == "gpt-3.5-turbo" -@pytest.mark.asyncio -@patch("litellm.amoderation") -async def test_arouter_amoderation_with_credential_name(mock_amoderation): - """ - Test that router.amoderation passes litellm_credential_name to the underlying litellm.amoderation call - """ - mock_amoderation.return_value = AsyncMock() - - router = litellm.Router( - model_list=[ - { - "model_name": "text-moderation-stable", - "litellm_params": { - "model": "text-moderation-stable", - "litellm_credential_name": "my-custom-auth", - }, - }, - ], - ) - - await router.amoderation(input="I love everyone!", model="text-moderation-stable") - - mock_amoderation.assert_called_once() - call_kwargs = mock_amoderation.call_args[1] # Get the kwargs of the call - print( - "call kwargs for router.amoderation=", - json.dumps(call_kwargs, indent=4, default=str), - ) - assert call_kwargs["litellm_credential_name"] == "my-custom-auth" - assert call_kwargs["model"] == "text-moderation-stable" def test_arouter_test_team_model(): @@ -17620,3 +17590,242 @@ class TestMemberAutoRouterInference: monkeypatch.setitem(sys.modules, "fastapi", None) monkeypatch.delitem(sys.modules, "litellm.proxy.auth.auto_router_checks", raising=False) assert (await self._route(router, {"metadata": {"user_api_key_team_id": "router-team"}})).model == "restricted-model" + + +def _access_window_offsets(start_hours: float, end_hours: float, team_ids: list) -> dict: + now_utc = datetime.now(timezone.utc) + return { + "start": (now_utc + timedelta(hours=start_hours)).strftime("%H:%M"), + "end": (now_utc + timedelta(hours=end_hours)).strftime("%H:%M"), + "timezone": "UTC", + "team_ids": team_ids, + } + + +def _reserved_model_list(windows_for_reserved=None, windows_for_open=None) -> list: + reserved: dict = { + "model_name": "gpt-4o-ptu", + "litellm_params": {"model": "gpt-4o", "mock_response": "reserved"}, + "model_info": {"id": "reserved-deployment"}, + } + if windows_for_reserved is not None: + reserved["model_info"]["access_windows"] = windows_for_reserved + unreserved: dict = { + "model_name": "gpt-4o-ptu", + "litellm_params": {"model": "gpt-4o", "mock_response": "open"}, + "model_info": {"id": "open-deployment"}, + } + if windows_for_open is not None: + unreserved["model_info"]["access_windows"] = windows_for_open + return [reserved, unreserved] + + +def test_access_windows_hide_reserved_deployment_from_other_teams(): + router = Router( + model_list=_reserved_model_list( + windows_for_reserved=[_access_window_offsets(-1, 1, ["team-a"])], + ), + ) + _, deployments = router._common_checks_available_deployment( + model="gpt-4o-ptu", + request_kwargs={"metadata": {"user_api_key_team_id": "team-b"}}, + ) + assert [d["model_info"]["id"] for d in deployments] == ["open-deployment"] + + +def test_access_windows_raise_when_only_reserved_deployments_remain(): + router = Router(model_list=_reserved_model_list( + windows_for_reserved=[_access_window_offsets(-1, 1, ["team-a"])], + windows_for_open=[_access_window_offsets(-1, 1, ["team-a"])], + )[:1]) + for request_kwargs in ({"metadata": {"user_api_key_team_id": "team-b"}}, {}): + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._common_checks_available_deployment(model="gpt-4o-ptu", request_kwargs=request_kwargs) + _, deployments = router._common_checks_available_deployment( + model="gpt-4o-ptu", + request_kwargs={"metadata": {"user_api_key_team_id": "team-a"}}, + ) + assert [d["model_info"]["id"] for d in deployments] == ["reserved-deployment"] + + +def test_reserved_deployments_drop_strategy_markers_before_filtering(): + router = Router(model_list=_reserved_model_list()[:1]) + marker = {"model_name": "gpt-4o-ptu", "litellm_params": {"model": "auto_router/semantic"}} + reserved = { + "model_name": "gpt-4o-ptu", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"access_windows": [_access_window_offsets(-1, 1, ["team-a"])]}, + } + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._filter_reserved_deployments( + model="gpt-4o-ptu", + healthy_deployments=[marker, reserved], + request_team_id="team-b", + ) + + +def test_access_windows_invalid_timezone_fails_router_construction(): + with pytest.raises(ValueError, match=r"gpt-4o-ptu.*access_windows"): + Router( + model_list=[ + { + "model_name": "gpt-4o-ptu", + "litellm_params": {"model": "gpt-4o", "mock_response": "x"}, + "model_info": { + "access_windows": [ + { + "start": "22:00", + "end": "06:00", + "timezone": "Mars/Olympus", + "team_ids": ["team-a"], + } + ] + }, + } + ], + ) + + +def test_access_windows_inactive_window_leaves_deployments_available(): + router = Router( + model_list=_reserved_model_list( + windows_for_reserved=[_access_window_offsets(2, 3, ["team-a"])], + ), + ) + _, deployments = router._common_checks_available_deployment( + model="gpt-4o-ptu", + request_kwargs={"metadata": {"user_api_key_team_id": "team-b"}}, + ) + assert {d["model_info"]["id"] for d in deployments} == {"reserved-deployment", "open-deployment"} + + +def test_access_windows_apply_when_calling_by_model_id(): + router = Router(model_list=_reserved_model_list( + windows_for_reserved=[_access_window_offsets(-1, 1, ["team-a"])], + )) + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._common_checks_available_deployment( + model="reserved-deployment", + request_kwargs={"metadata": {"user_api_key_team_id": "team-b"}}, + ) + _, deployment = router._common_checks_available_deployment( + model="reserved-deployment", + request_kwargs={"metadata": {"user_api_key_team_id": "team-a"}}, + ) + assert deployment["model_info"]["id"] == "reserved-deployment" + + +def test_access_windows_apply_when_calling_by_litellm_model_name(): + router = Router( + model_list=[ + { + "model_name": "gpt-4o-ptu", + "litellm_params": { + "model": "openai/gpt-5.6-bypass-probe", + "mock_response": "reserved", + }, + "model_info": { + "id": "reserved-litellm-model", + "access_windows": [_access_window_offsets(-1, 1, ["team-a"])], + }, + } + ], + ) + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._common_checks_available_deployment( + model="openai/gpt-5.6-bypass-probe", + request_kwargs={"metadata": {"user_api_key_team_id": "team-b"}}, + ) + _, deployments = router._common_checks_available_deployment( + model="openai/gpt-5.6-bypass-probe", + request_kwargs={"metadata": {"user_api_key_team_id": "team-a"}}, + ) + assert [d["model_info"]["id"] for d in deployments] == ["reserved-litellm-model"] + + +def test_access_windows_apply_to_specific_deployment_calls(): + router = Router( + model_list=[ + { + "model_name": "gpt-4o-ptu", + "litellm_params": { + "model": "openai/gpt-5.6-specific-probe", + "mock_response": "reserved", + }, + "model_info": { + "id": "reserved-specific", + "access_windows": [_access_window_offsets(-1, 1, ["team-a"])], + }, + } + ], + ) + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._common_checks_available_deployment( + model="openai/gpt-5.6-specific-probe", + specific_deployment=True, + request_kwargs={"metadata": {"user_api_key_team_id": "team-b"}}, + ) + _, deployments = router._common_checks_available_deployment( + model="openai/gpt-5.6-specific-probe", + specific_deployment=True, + request_kwargs={"metadata": {"user_api_key_team_id": "team-a"}}, + ) + assert [d["model_info"]["id"] for d in deployments] == ["reserved-specific"] + + +def test_access_windows_apply_to_wildcard_early_resolve(): + router = Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*", "mock_response": "reserved"}, + "model_info": { + "id": "reserved-wildcard", + "access_windows": [_access_window_offsets(-1, 1, ["team-a"])], + }, + } + ], + ) + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._common_checks_available_deployment( + model="openai/gpt-probe-wildcard", + request_kwargs={"metadata": {"user_api_key_team_id": "team-b"}}, + ) + _, deployments = router._common_checks_available_deployment( + model="openai/gpt-probe-wildcard", + request_kwargs={"metadata": {"user_api_key_team_id": "team-a"}}, + ) + assert [d["model_info"]["id"] for d in deployments] == ["reserved-wildcard"] + + +def test_access_windows_filter_reserved_deployments_method(): + router = Router(model_list=_reserved_model_list()) + reserved: dict = { + "model_info": { + "id": "reserved-deployment", + "access_windows": [_access_window_offsets(-1, 1, ["team-a"])], + } + } + open_deployment: dict = {"model_info": {"id": "open-deployment"}} + assert [ + d["model_info"]["id"] + for d in router._filter_reserved_deployments( + model="gpt-4o-ptu", + healthy_deployments=[reserved, open_deployment], + request_team_id="team-b", + ) + ] == ["open-deployment"] + with pytest.raises(litellm.BadRequestError, match="reserved for another team"): + router._filter_reserved_deployments( + model="gpt-4o-ptu", + healthy_deployments=[reserved], + request_team_id="team-b", + ) + assert [ + d["model_info"]["id"] + for d in router._filter_reserved_deployments( + model="gpt-4o-ptu", + healthy_deployments=[reserved, open_deployment], + request_team_id="team-a", + ) + ] == ["reserved-deployment", "open-deployment"] diff --git a/tests/test_litellm/test_router_exception_redaction.py b/tests/test_litellm/test_router_exception_redaction.py index 6754775db22..5b52496fc1c 100644 --- a/tests/test_litellm/test_router_exception_redaction.py +++ b/tests/test_litellm/test_router_exception_redaction.py @@ -26,8 +26,8 @@ Five leak sites are gated in `litellm/router.py`: 1. Deployment timeout debug after `litellm.Timeout` 2. ContextWindowExceededError fallback hint 3. ContentPolicyViolationError fallback hint -4. "No fallback model group found for..." when fallbacks dict misses -5. "Received Model Group=...\\nAvailable Model Group Fallbacks=..." +4. "no fallback model group was found" when fallbacks dict misses +5. "model group '...' failed with the error above" plus the fallback outcome (always fires on terminal raise from the fallback orchestrator) Site 5 is the broadest — it fires for every failing call that goes @@ -42,8 +42,9 @@ import pytest import litellm from litellm import Router -_RECEIVED_MODEL_GROUP_PHRASE = "Received Model Group=" -_AVAILABLE_FALLBACKS_PHRASE = "Available Model Group Fallbacks=" +_RECEIVED_MODEL_GROUP_PHRASE = "failed with the error above" +_AVAILABLE_FALLBACKS_PHRASE = "No fallback was attempted" +_NO_FALLBACK_GROUP_PHRASE = "no fallback model group was found" _CONTEXT_WINDOW_HINT_PHRASE = "context_window_fallbacks=" _INTERNAL_MODEL_GROUP_NAME = "all-anthropic/claude-secret-internal" _FALLBACK_CREDENTIAL = "sk-INLINEFALLBACKSECRET1234567890" @@ -124,7 +125,7 @@ def test_flag_defaults_on(): assert litellm.expose_router_debug_in_errors is True -# --- Site 5: "Received Model Group=..." on terminal raise -------------------- +# --- Site 5: fallback outcome on terminal raise -------------------- @pytest.mark.asyncio @@ -192,7 +193,7 @@ async def test_flag_on_shows_context_window_fallback_hint(monkeypatch: pytest.Mo assert _INTERNAL_MODEL_GROUP_NAME in msg, msg -# --- Site 4: "No fallback model group found..." when fallbacks miss --------- +# --- Site 4: "no fallback model group was found" when fallbacks miss --------- @pytest.mark.asyncio @@ -221,7 +222,7 @@ async def test_flag_off_does_not_leak_when_no_fallback_group_found(monkeypatch: messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - assert "No fallback model group found" not in msg, msg + assert _NO_FALLBACK_GROUP_PHRASE not in msg, msg assert "some-other-group" not in msg, msg assert _INTERNAL_MODEL_GROUP_NAME not in msg, msg @@ -250,8 +251,12 @@ async def test_flag_on_shows_when_no_fallback_group_found(monkeypatch: pytest.Mo messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - assert "No fallback model group found" in msg, msg - assert _INTERNAL_MODEL_GROUP_NAME in msg, msg + assert _NO_FALLBACK_GROUP_PHRASE in msg, msg + assert f"model group '{_INTERNAL_MODEL_GROUP_NAME}' failed with the error above" in msg, msg + assert "Fallbacks are configured for: some-other-group" in msg, msg + assert "not retried on another model" in msg, msg + assert _AVAILABLE_FALLBACKS_PHRASE not in msg, msg + assert msg.count("failed with the error above") == 1, msg # --- Site 1: Deployment timeout debug on litellm.Timeout -------------------- @@ -349,6 +354,30 @@ async def test_flag_on_shows_content_policy_fallback_hint(monkeypatch: pytest.Mo assert _INTERNAL_MODEL_GROUP_NAME in msg, msg +@pytest.mark.asyncio +async def test_flag_on_explains_failed_content_policy_fallback(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "expose_router_debug_in_errors", True) + router = Router( + model_list=[ + {"model_name": _INTERNAL_MODEL_GROUP_NAME, "litellm_params": {"model": "gpt-4o", "api_key": "key"}}, + {"model_name": "policy-safe-group", "litellm_params": {"model": "gpt-4o", "api_key": "key"}}, + ], + content_policy_fallbacks=[{_INTERNAL_MODEL_GROUP_NAME: ["policy-safe-group"]}], + num_retries=0, + ) + with pytest.raises(litellm.ContentPolicyViolationError) as excinfo: + await router.acompletion( + model=_INTERNAL_MODEL_GROUP_NAME, + messages=[{"role": "user", "content": "hi"}], + mock_response=_content_policy_error(), + ) + msg = excinfo.value.message + assert f"model group '{_INTERNAL_MODEL_GROUP_NAME}' failed with the error above" in msg, msg + assert "Fallback to policy-safe-group also failed: " in msg, msg + assert _AVAILABLE_FALLBACKS_PHRASE not in msg, msg + assert msg.count("failed with the error above") == 1, msg + + # --- Credential masking: raw provider keys never leak, either flag state ---- @@ -387,7 +416,7 @@ async def test_flag_on_masks_fallback_credentials(monkeypatch: pytest.MonkeyPatc async def test_flag_on_scrubs_credential_from_inner_fallback_exception_string(monkeypatch: pytest.MonkeyPatch): """If the fallback attempt itself raises an exception whose message embeds a raw provider credential (e.g. a provider SDK echoing back the api_key it was - called with), that string is re-embedded via `Error doing the fallback: ...` + called with), that string is re-embedded via `Fallback to ... also failed: ...` on the terminal raise. The router must scrub known secret patterns from it. The primary fails with a benign rate-limit; the fallback deployment fails with an exception whose text contains the secret.""" @@ -423,6 +452,9 @@ async def test_flag_on_scrubs_credential_from_inner_fallback_exception_string(mo messages=[{"role": "user", "content": "hi"}], ) msg = excinfo.value.message - assert "Error doing the fallback:" in msg, msg + assert f"model group '{_INTERNAL_MODEL_GROUP_NAME}' failed with the error above" in msg, msg + assert "Fallback to fallback-group also failed: " in msg, msg + assert "content_filter_policy" in msg, msg + assert msg.count("failed with the error above") == 1, msg assert inner_secret not in msg, msg assert "REDACTED" in msg, msg diff --git a/tests/test_litellm/test_together_ai_model_metadata.py b/tests/test_litellm/test_together_ai_model_metadata.py index 7176ba4f219..dccba3d66f9 100644 --- a/tests/test_litellm/test_together_ai_model_metadata.py +++ b/tests/test_litellm/test_together_ai_model_metadata.py @@ -95,15 +95,6 @@ def _successor(info: dict[str, object]) -> str | None: return successor if isinstance(successor, str) else None -def test_together_successor_metadata_points_at_known_models(cost_map: CostMap): - successors = { - model: successor - for model, info in cost_map.items() - if model.startswith("together_ai/") and (successor := _successor(info)) is not None - } - assert len(successors) >= 10 - for model, successor in successors.items(): - assert successor in cost_map, f"{model} names successor {successor} that is not in the map" def test_together_backup_cost_map_in_sync(cost_map: CostMap): diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index ead805eed42..07982f51153 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1465,12 +1465,6 @@ class TestProxyFunctionCalling: ("gemini/gemini-2.5-pro", "litellm_proxy/gemini/gemini-2.5-pro", True), ("gemini/gemini-2.5-flash", "litellm_proxy/gemini/gemini-2.5-flash", True), # Groq models (mixed support) - ("groq/gemma-7b-it", "litellm_proxy/groq/gemma-7b-it", True), - ( - "groq/llama-3.3-70b-versatile", - "litellm_proxy/groq/llama-3.3-70b-versatile", - True, - ), # Cohere models (generally don't support function calling) ("command-nightly", "litellm_proxy/command-nightly", False), ], diff --git a/tests/test_litellm/test_xai_responses_auto_routing.py b/tests/test_litellm/test_xai_responses_auto_routing.py index d405ea1e6c6..c31f599dd79 100644 --- a/tests/test_litellm/test_xai_responses_auto_routing.py +++ b/tests/test_litellm/test_xai_responses_auto_routing.py @@ -46,34 +46,6 @@ class TestXAIResponsesAutoRouting: assert model_info.get("mode") != "responses" assert updated_model == model - def test_responses_api_bridge_check_with_tools(self): - """Test that with tools, xAI automatically routes to Responses API""" - model = "grok-3" - custom_llm_provider = "xai" - tools = [ - { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the weather", - "parameters": { - "type": "object", - "properties": {"location": {"type": "string"}}, - }, - }, - } - ] - web_search_options = None - - model_info, updated_model = responses_api_bridge_check( - model=model, - custom_llm_provider=custom_llm_provider, - web_search_options=web_search_options, - ) - - # Should auto-route to responses mode when tools are present - assert model_info.get("mode") == "chat" - assert updated_model == model def test_responses_api_bridge_check_with_empty_tools(self): """Test that with empty tools list, xAI does not route to Responses API""" @@ -134,57 +106,8 @@ class TestXAIResponsesAutoRouting: assert model_info.get("mode") == "responses" assert updated_model == "grok-3" # prefix removed - def test_responses_api_bridge_check_with_code_interpreter_tool(self): - """Test auto-routing with code_interpreter tool""" - model = "grok-3" - custom_llm_provider = "xai" - tools = [{"type": "code_interpreter"}] - web_search_options = None - model_info, updated_model = responses_api_bridge_check( - model=model, - custom_llm_provider=custom_llm_provider, - web_search_options=web_search_options, - ) - # Should auto-route with code_interpreter tool - assert model_info.get("mode") == "chat" - assert updated_model == model - def test_responses_api_bridge_check_with_web_search_tool(self): - """Test auto-routing with web_search tool""" - model = "grok-4" - custom_llm_provider = "xai" - tools = [ - {"type": "web_search", "filters": {"allowed_domains": ["wikipedia.org"]}} - ] - web_search_options = None - - model_info, updated_model = responses_api_bridge_check( - model=model, - custom_llm_provider=custom_llm_provider, - web_search_options=web_search_options, - ) - - # Should auto-route with web_search tool - assert model_info.get("mode") == "chat" - assert updated_model == model - - def test_responses_api_bridge_check_with_x_search_tool(self): - """Test auto-routing with x_search tool""" - model = "grok-4" - custom_llm_provider = "xai" - tools = [{"type": "x_search", "allowed_x_handles": ["@elonmusk"]}] - web_search_options = None - - model_info, updated_model = responses_api_bridge_check( - model=model, - custom_llm_provider=custom_llm_provider, - web_search_options=web_search_options, - ) - - # Should auto-route with x_search tool - assert model_info.get("mode") == "chat" - assert updated_model == model def test_responses_api_bridge_check_with_web_search_options(self): """Test auto-routing with web_search_options""" diff --git a/tests/test_litellm/types/test_router.py b/tests/test_litellm/types/test_router.py index 7fcb76b638f..4d4c326d1ca 100644 --- a/tests/test_litellm/types/test_router.py +++ b/tests/test_litellm/types/test_router.py @@ -91,7 +91,7 @@ def test_pricing_strings_are_coerced_to_float(): def test_invalid_pricing_is_rejected(): - with pytest.raises(ValueError, match='validation error for ModelInfo'): + with pytest.raises(ValueError, match="validation error for ModelInfo"): ModelInfo(id="x", input_cost_per_token="free") @@ -118,7 +118,9 @@ def test_drop_params_ignores_non_flag_non_string_values_with_a_warning(value, ca assert f"drop_params={value!r} is not a flag value" in caplog.text -@pytest.mark.parametrize("value", [True, "true", None, "os.environ/DROP_PARAMS", "v2:gcm:ciphertext-from-a-pre-fix-row"]) +@pytest.mark.parametrize( + "value", [True, "true", None, "os.environ/DROP_PARAMS", "v2:gcm:ciphertext-from-a-pre-fix-row"] +) def test_drop_params_flags_and_strings_log_nothing(value, caplog): with caplog.at_level(logging.WARNING, logger="LiteLLM"): GenericLiteLLMParams(drop_params=value) @@ -146,3 +148,88 @@ def test_aws_session_tags_round_trip_as_sts_shaped_pairs(): def test_aws_session_tags_reject_shapes_sts_would_refuse(aws_session_tags): with pytest.raises(ValidationError, match="aws_session_tags"): LiteLLM_Params(model="bedrock/anthropic.claude-opus-5", aws_session_tags=aws_session_tags) + + +def test_provider_affinity_header_is_normalized(): + params = LiteLLM_Params( + model="openai/gpt-4o-mini", + provider_affinity_header="X-Conversation-Id", + ) + + assert params.provider_affinity_header == "X-Conversation-Id" + assert params.model_dump(exclude_none=True)["provider_affinity_header"] == "X-Conversation-Id" + + +@pytest.mark.parametrize( + "header", + [ + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + "Host", + "Content-Length", + "Content-Type", + "X-API-Key", + ], +) +def test_provider_affinity_header_rejects_sensitive_or_transport_headers(header: str): + with pytest.raises(ValueError, match="provider_affinity_header"): + LiteLLM_Params( + model="openai/gpt-4o-mini", + provider_affinity_header=header, + ) + + +@pytest.mark.parametrize("header", ["", "X Conversation Id", "X-Conversation-Id\r\nInjected: true"]) +def test_provider_affinity_header_rejects_invalid_header_names(header: str): + with pytest.raises(ValueError, match="provider_affinity_header"): + LiteLLM_Params( + model="openai/gpt-4o-mini", + provider_affinity_header=header, + ) + + +def test_model_info_parses_access_windows_time_strings(): + import datetime + + info = ModelInfo( + id="x", + access_windows=[ + { + "start": "22:00", + "end": "06:00", + "timezone": "America/New_York", + "team_ids": ["team-nightly"], + } + ], + ) + window = info.access_windows[0] + assert window.start == datetime.time(22, 0) + assert window.end == datetime.time(6, 0) + assert window.timezone == "America/New_York" + assert window.team_ids == ("team-nightly",) + + +@pytest.mark.parametrize( + "access_windows", + [ + [{"start": "25:00", "end": "06:00", "timezone": "UTC", "team_ids": ["t"]}], + [{"start": "22:00", "end": "06:00", "timezone": "Mars/Olympus", "team_ids": ["t"]}], + [{"start": "22:00", "end": "06:00", "timezone": "UTC", "team_ids": []}], + ], + ids=["invalid-time", "unknown-timezone", "empty-team-ids"], +) +def test_model_info_rejects_invalid_access_windows(access_windows): + with pytest.raises(ValidationError): + ModelInfo(id="x", access_windows=access_windows) + + +def test_model_info_rejects_offset_aware_access_window_times(): + with pytest.raises(ValidationError): + ModelInfo( + id="x", + access_windows=[ + {"start": "22:00+05:00", "end": "06:00", "timezone": "UTC", "team_ids": ["t"]} + ], + ) diff --git a/tests/test_litellm_rust/messages/test_callbacks.py b/tests/test_litellm_rust/messages/test_callbacks.py index b55bc47d640..d5dc5437398 100644 --- a/tests/test_litellm_rust/messages/test_callbacks.py +++ b/tests/test_litellm_rust/messages/test_callbacks.py @@ -5,7 +5,11 @@ import pytest import litellm from litellm.integrations.custom_logger import CustomLogger +from litellm.rust_bridge import catalog +from litellm.rust_bridge.catalog import Route, RouteRule +from litellm.rust_bridge.configuration import Rollout from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging +from tests.test_litellm_rust.support.isolation import rebound from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec from tests.test_litellm_rust.support.requests import ( MESSAGES, @@ -20,6 +24,12 @@ pytestmark = pytest.mark.requires_rust_extension STREAM: Final = ResponseSpec(body=None, events=MESSAGES_EVENTS) +@pytest.fixture(autouse=True) +def opt_messages_into_rust() -> Iterator[None]: + with rebound(catalog, "RULES", (RouteRule(Route.MESSAGES, Rollout.RUST_OPT_IN), *catalog.RULES)): + yield + + @pytest.fixture def messages_server(recording_server: RecordingServer) -> RecordingServer: recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE) diff --git a/tests/test_litellm_rust/test_cache.py b/tests/test_litellm_rust/test_cache.py index 0f389edaa27..e2e2f9f1819 100644 --- a/tests/test_litellm_rust/test_cache.py +++ b/tests/test_litellm_rust/test_cache.py @@ -15,7 +15,7 @@ from contextlib import ExitStack from datetime import datetime from pathlib import Path from types import SimpleNamespace -from typing import Final, Protocol, cast +from typing import Final, Protocol, TypeAlias, cast from unittest.mock import Mock from urllib.parse import urlparse from uuid import uuid4 @@ -37,10 +37,10 @@ from litellm.caching.in_memory_cache import InMemoryCache from litellm.caching.redis_cluster_cache import RedisClusterCache from litellm.caching.redis_semantic_cache import RedisSemanticCache from litellm.caching.s3_cache import S3Cache -from litellm.rust_bridge import _native +from litellm.rust_bridge import _native, catalog from litellm.rust_bridge.catalog import CacheRule, Route, RouteRule, SecretManagerRule from litellm.rust_bridge.configuration import Rollout -from litellm.rust_bridge.response_cache import ResponseCacheRuntime, resolve_response_cache +from litellm.rust_bridge.response_cache import NativeResponseCacheRuntime, ResponseCacheRuntime, resolve_response_cache from litellm.types.caching import LiteLLMCacheType from litellm.types.llms.custom_llm import CustomLLMItem from litellm.types.utils import EmbeddingResponse @@ -1843,9 +1843,7 @@ async def test_qdrant_semantic_async_parity(qdrant_url: str, fake_embedding_endp assert python_value["response"] == {"id": "native"} -async def test_qdrant_semantic_async_store_batch_shares_entries( - qdrant_url: str, fake_embedding_endpoint: str -) -> None: +async def test_qdrant_semantic_async_store_batch_shares_entries(qdrant_url: str, fake_embedding_endpoint: str) -> None: del fake_embedding_endpoint collection: Final = f"cache_{uuid4().hex}" facade: Final = qdrant_facade(qdrant_url, collection) @@ -1865,14 +1863,12 @@ async def test_qdrant_semantic_async_store_batch_shares_entries( assert binding.lookup(entries[0]) == {"id": "one"} assert binding.lookup(entries[1]) == {"id": "two"} - assert ( - (await facade.cache.async_get_cache("batch-one", messages=entries[0]["messages"]))["response"] - == {"id": "one"} - ) - assert ( - (await facade.cache.async_get_cache("batch-two", messages=entries[1]["messages"]))["response"] - == {"id": "two"} - ) + assert (await facade.cache.async_get_cache("batch-one", messages=entries[0]["messages"]))["response"] == { + "id": "one" + } + assert (await facade.cache.async_get_cache("batch-two", messages=entries[1]["messages"]))["response"] == { + "id": "two" + } async def test_qdrant_semantic_malformed_entries_and_unsupported_operations( @@ -1962,3 +1958,389 @@ def test_qdrant_semantic_mutation_and_projection_fallback(qdrant_url: str, fake_ unsupported.cache.qdrant_api_base = "http://127.0.0.1:7777" with pytest.raises(TypeError, match="gRPC"): handle._bind_facade(unsupported) + + +CacheFactory: TypeAlias = Callable[[], Cache] + + +def require_rust(monkeypatch: pytest.MonkeyPatch, backend: LiteLLMCacheType) -> None: + monkeypatch.setattr(catalog, "RULES", (CacheRule(Rollout.RUST_REQUIRED, backends=frozenset({backend})),)) + + +def native_runtime(facade: Cache) -> ResponseCacheRuntime: + runtime: Final = facade._native_cache # pyright: ignore[reportPrivateUsage] # the activation under test has no public accessor + assert isinstance(runtime, ResponseCacheRuntime) + assert runtime.kind == "native" + return runtime + + +@pytest.fixture +def cache_factory(request: pytest.FixtureRequest, tmp_path: Path) -> CacheFactory: + backend: Final = cast(LiteLLMCacheType, request.param) + match backend: + case LiteLLMCacheType.LOCAL: + return lambda: Cache(type=backend) + case LiteLLMCacheType.DISK: + return lambda: Cache(type=backend, disk_cache_dir=str(tmp_path)) + case LiteLLMCacheType.REDIS: + parsed: Final = urlparse(cast(str, request.getfixturevalue("redis_url"))) + return lambda: Cache(type=backend, host=parsed.hostname, port=str(parsed.port)) + case LiteLLMCacheType.S3: + stub: Final = cast(S3Stub, request.getfixturevalue("s3_stub")) + return lambda: Cache( + type=backend, + s3_bucket_name="cache-bucket", + s3_region_name="us-east-1", + s3_endpoint_url=stub.url, + s3_aws_access_key_id="key", + s3_aws_secret_access_key="secret", + s3_path="team", + ) + case LiteLLMCacheType.GCS: + return lambda: Cache(type=backend, gcs_bucket_name="bucket", gcs_path="cache/") + case LiteLLMCacheType.REDIS_SEMANTIC: + return lambda: Cache( + type=backend, + redis_url="redis://127.0.0.1:6379", + similarity_threshold=0.8, + redis_semantic_cache_embedding_model="text-embedding-3-small", + ) + case LiteLLMCacheType.VALKEY_SEMANTIC: + return lambda: Cache(type=backend, redis_url="redis://127.0.0.1:6390/0", similarity_threshold=0.8) + case _: + raise AssertionError(f"no local factory for {backend}") + + +ROUND_TRIP_BACKENDS: Final = ( + LiteLLMCacheType.LOCAL, + LiteLLMCacheType.DISK, + LiteLLMCacheType.REDIS, + LiteLLMCacheType.S3, +) +SHARED_STORE_BACKENDS: Final = (LiteLLMCacheType.DISK, LiteLLMCacheType.REDIS, LiteLLMCacheType.S3) + + +def completion_kwargs(label: str) -> dict[str, object]: + return {"model": "gpt-4o", "messages": [{"role": "user", "content": f"{label} {uuid4().hex}"}]} + + +@pytest.mark.parametrize("backend", list(LiteLLMCacheType)) +def test_shipped_rules_keep_every_backend_on_python(backend: LiteLLMCacheType) -> None: + assert resolve_response_cache(cast(Cache, SimpleNamespace(type=backend))) is None + + +@pytest.mark.parametrize( + "cache_factory", + [ + LiteLLMCacheType.LOCAL, + LiteLLMCacheType.DISK, + LiteLLMCacheType.REDIS, + LiteLLMCacheType.S3, + LiteLLMCacheType.GCS, + LiteLLMCacheType.REDIS_SEMANTIC, + LiteLLMCacheType.VALKEY_SEMANTIC, + ], + indirect=True, +) +def test_shipped_rules_construct_python_backed_facades(cache_factory: CacheFactory) -> None: + assert cache_factory()._native_cache is None # pyright: ignore[reportPrivateUsage] # the activation under test has no public accessor + + +@pytest.mark.parametrize( + "cache_factory", + [ + LiteLLMCacheType.LOCAL, + LiteLLMCacheType.DISK, + LiteLLMCacheType.REDIS, + LiteLLMCacheType.S3, + LiteLLMCacheType.GCS, + LiteLLMCacheType.REDIS_SEMANTIC, + LiteLLMCacheType.VALKEY_SEMANTIC, + ], + indirect=True, +) +def test_rust_required_rule_activates_the_native_backend( + cache_factory: CacheFactory, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + require_rust(monkeypatch, cast(LiteLLMCacheType, request.node.callspec.params["cache_factory"])) + native_runtime(cache_factory()) + + +@pytest.mark.parametrize("cache_factory", ROUND_TRIP_BACKENDS, indirect=True) +async def test_facade_storage_calls_round_trip_through_the_native_backend( + cache_factory: CacheFactory, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + require_rust(monkeypatch, cast(LiteLLMCacheType, request.node.callspec.params["cache_factory"])) + facade: Final = cache_factory() + native_runtime(facade) + + sync_kwargs: Final = completion_kwargs("sync") + facade.add_cache({"answer": 1}, **sync_kwargs) + assert facade.get_cache(**sync_kwargs) == {"answer": 1} + + async_kwargs: Final = completion_kwargs("async") + await facade.async_add_cache({"answer": 2}, **async_kwargs) + assert await facade.async_get_cache(**async_kwargs) == {"answer": 2} + assert facade.get_cache(**completion_kwargs("absent")) is None + + +async def test_memory_facade_writes_bypass_the_python_backend(monkeypatch: pytest.MonkeyPatch) -> None: + require_rust(monkeypatch, LiteLLMCacheType.LOCAL) + facade: Final = Cache(type=LiteLLMCacheType.LOCAL) + native_runtime(facade) + kwargs: Final = completion_kwargs("memory") + facade.add_cache({"answer": 1}, **kwargs) + assert facade.cache.get_cache(facade.get_cache_key(**kwargs)) is None + assert facade.get_cache(**kwargs) == {"answer": 1} + + +@pytest.mark.parametrize("cache_factory", SHARED_STORE_BACKENDS, indirect=True) +async def test_native_and_python_facades_share_one_wire_format( + cache_factory: CacheFactory, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + python_facade: Final = cache_factory() + assert python_facade._native_cache is None # pyright: ignore[reportPrivateUsage] # the activation under test has no public accessor + require_rust(monkeypatch, cast(LiteLLMCacheType, request.node.callspec.params["cache_factory"])) + native_facade: Final = cache_factory() + native_runtime(native_facade) + + native_written: Final = completion_kwargs("native") + native_facade.add_cache({"writer": "native"}, **native_written) + assert python_facade.get_cache(**native_written) == {"writer": "native"} + + python_written: Final = completion_kwargs("python") + python_facade.add_cache({"writer": "python"}, **python_written) + assert native_facade.get_cache(**python_written) == {"writer": "python"} + + async_native: Final = completion_kwargs("async-native") + await native_facade.async_add_cache({"writer": "async-native"}, **async_native) + assert await python_facade.async_get_cache(**async_native) == {"writer": "async-native"} + + async_python: Final = completion_kwargs("async-python") + await python_facade.async_add_cache({"writer": "async-python"}, **async_python) + assert await native_facade.async_get_cache(**async_python) == {"writer": "async-python"} + + +@pytest.mark.parametrize("cache_factory", ROUND_TRIP_BACKENDS, indirect=True) +async def test_embedding_pipeline_stores_one_native_entry_per_input( + cache_factory: CacheFactory, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest +) -> None: + require_rust(monkeypatch, cast(LiteLLMCacheType, request.node.callspec.params["cache_factory"])) + facade: Final = cache_factory() + native_runtime(facade) + inputs: Final = [f"alpha {uuid4().hex}", f"beta {uuid4().hex}"] + result: Final = EmbeddingResponse( + model="text-embedding-3-small", + data=[ + {"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}, + {"object": "embedding", "index": 1, "embedding": [0.3, 0.4]}, + ], + ) + await facade.async_add_cache_pipeline(result, model="text-embedding-3-small", input=inputs) + + keys: Final = [facade.get_cache_key(model="text-embedding-3-small", input=text) for text in inputs] + assert len(set(keys)) == len(inputs) + for text, expected in zip(inputs, ([0.1, 0.2], [0.3, 0.4]), strict=True): + cached = await facade.async_get_cache(model="text-embedding-3-small", input=text) + assert isinstance(cached, dict) + assert cached["embedding"] == expected + assert await facade.async_get_cache(model="text-embedding-3-small", input=inputs) is None + + +def redis_facade(redis_url: str, **settings: object) -> Cache: + parsed: Final = urlparse(redis_url) + return Cache(type=LiteLLMCacheType.REDIS, host=parsed.hostname, port=str(parsed.port), **settings) + + +@pytest.mark.parametrize( + ("settings", "message"), + [ + pytest.param({"max_connections": 10}, "max_connections requires Python", id="pool-size"), + pytest.param({"socket_timeout": 1.0}, "socket_timeout and socket_connect_timeout", id="socket-timeout"), + pytest.param( + {"socket_connect_timeout": 1.0}, "socket_timeout and socket_connect_timeout", id="connect-timeout" + ), + pytest.param({"socket_keepalive": True}, "does not support socket_keepalive", id="keepalive"), + pytest.param({"health_check_interval": 5}, "does not support health_check_interval", id="health-check"), + pytest.param({"client_name": "litellm"}, "does not support client_name", id="client-name"), + pytest.param({"ssl": True}, "ssl_check_hostname=false require Python", id="tls-default-hostname-check"), + pytest.param({"ssl": True, "ssl_cert_reqs": "none"}, "ssl_cert_reqs=none", id="tls-without-verification"), + pytest.param( + {"ssl": True, "ssl_check_hostname": True, "ssl_ca_certs": "/ca.pem"}, + "does not support ssl_ca_certs", + id="tls-custom-ca", + ), + pytest.param( + {"ssl": True, "ssl_check_hostname": True, "ssl_certfile": "/client.pem", "ssl_keyfile": "/client.key"}, + "does not support ssl_ca_certs, ssl_ca_data, ssl_certfile or ssl_keyfile", + id="tls-client-certificate", + ), + ], +) +def test_redis_settings_the_native_client_cannot_honor_decline( + redis_url: str, monkeypatch: pytest.MonkeyPatch, settings: dict[str, object], message: str +) -> None: + require_rust(monkeypatch, LiteLLMCacheType.REDIS) + with pytest.raises(RuntimeError, match=f"declined the cache: native Redis.*{message}"): + redis_facade(redis_url, **settings) + + +def test_redis_verified_tls_activates_natively(redis_url: str, monkeypatch: pytest.MonkeyPatch) -> None: + require_rust(monkeypatch, LiteLLMCacheType.REDIS) + native_runtime(redis_facade(redis_url, ssl=True, ssl_check_hostname=True)) + + +async def test_redis_flush_size_buffers_native_facade_writes(redis_url: str, monkeypatch: pytest.MonkeyPatch) -> None: + require_rust(monkeypatch, LiteLLMCacheType.REDIS) + facade: Final = redis_facade(redis_url, redis_flush_size=2, namespace="team") + native_runtime(facade) + client: Final = redis.Redis.from_url(redis_url) + first: Final = completion_kwargs("first") + await facade.async_add_cache({"value": 1}, **first) + first_key: Final = facade.get_cache_key(**first) + assert first_key.startswith("team:") + assert client.get(first_key) is None + second: Final = completion_kwargs("second") + await facade.async_add_cache({"value": 2}, **second) + assert client.get(first_key) is not None + assert client.get(facade.get_cache_key(**second)) is not None + client.close() + + +@pytest.mark.parametrize( + ("backend", "settings", "message"), + [ + pytest.param( + LiteLLMCacheType.VALKEY_SEMANTIC, + {"redis_url": "rediss://127.0.0.1:6390/0", "similarity_threshold": 0.8}, + "native Valkey semantic cache does not support TLS connections", + id="valkey-tls", + ), + pytest.param( + LiteLLMCacheType.VALKEY_SEMANTIC, + {"redis_url": "redis://127.0.0.1:6390/0?socket_timeout=1", "similarity_threshold": 0.8}, + "native Redis uses fixed socket timeouts; socket_timeout and socket_connect_timeout require Python", + id="valkey-socket-timeout", + ), + pytest.param( + LiteLLMCacheType.REDIS_SEMANTIC, + {"redis_url": "rediss://127.0.0.1:6380", "similarity_threshold": 0.8}, + "native Redis semantic cache does not support TLS or query options in redis_url", + id="redis-semantic-tls", + ), + pytest.param( + LiteLLMCacheType.REDIS_SEMANTIC, + {"redis_url": "redis://127.0.0.1:6379?socket_timeout=1", "similarity_threshold": 0.8}, + "native Redis semantic cache does not support TLS or query options in redis_url", + id="redis-semantic-query", + ), + ], +) +def test_semantic_settings_the_native_client_cannot_honor_decline( + monkeypatch: pytest.MonkeyPatch, backend: LiteLLMCacheType, settings: dict[str, object], message: str +) -> None: + require_rust(monkeypatch, backend) + with pytest.raises(RuntimeError, match=f"declined the cache: {message}"): + Cache(type=backend, **settings) + + +def test_rust_with_fallback_keeps_python_when_the_native_client_declines( + redis_url: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + catalog, + "RULES", + (CacheRule(Rollout.RUST_OPT_OUT, backends=frozenset({LiteLLMCacheType.REDIS})),), + ) + assert redis_facade(redis_url, socket_timeout=1.0)._native_cache is None # pyright: ignore[reportPrivateUsage] # the activation under test has no public accessor + + +def test_qdrant_semantic_rust_required_rule_activates_natively( + qdrant_url: str, fake_embedding_endpoint: str, monkeypatch: pytest.MonkeyPatch +) -> None: + del fake_embedding_endpoint + require_rust(monkeypatch, LiteLLMCacheType.QDRANT_SEMANTIC) + facade: Final = qdrant_facade(qdrant_url, f"cache_{uuid4().hex}") + native_runtime(facade) + kwargs: Final = {"model": "gpt-4o", "messages": [{"role": "user", "content": "qdrant activation"}]} + facade.add_cache({"answer": "qdrant"}, **kwargs) + assert facade.get_cache(**kwargs) == {"answer": "qdrant"} + + +async def test_redis_semantic_rust_required_rule_activates_natively( + redis_stack: tuple[str, str], semantic_embedding: DeterministicEmbedding, monkeypatch: pytest.MonkeyPatch +) -> None: + del semantic_embedding + url, index = redis_stack + require_rust(monkeypatch, LiteLLMCacheType.REDIS_SEMANTIC) + facade: Final = Cache( + type=LiteLLMCacheType.REDIS_SEMANTIC, + redis_url=url, + similarity_threshold=0.8, + redis_semantic_cache_embedding_model=SEMANTIC_EMBEDDING_MODEL, + redis_semantic_cache_index_name=index, + ) + native_runtime(facade) + kwargs: Final = {"model": "gpt-4o", "messages": semantic_messages("name a primary color")} + await facade.async_add_cache({"answer": "blue"}, **kwargs) + assert await facade.async_get_cache(**kwargs) == {"answer": "blue"} + + +async def test_azure_blob_rust_required_rule_activates_natively(monkeypatch: pytest.MonkeyPatch) -> None: + account_url: Final = os.environ.get("AZURE_BLOB_CACHE_ACCOUNT_URL") + if account_url is None: + pytest.skip( + "live Azure Blob parity needs AZURE_BLOB_CACHE_ACCOUNT_URL plus DefaultAzureCredential inputs in the environment" + ) + require_rust(monkeypatch, LiteLLMCacheType.AZURE_BLOB) + facade: Final = Cache( + type=LiteLLMCacheType.AZURE_BLOB, + azure_account_url=account_url, + azure_blob_container=f"litellm-parity-{uuid.uuid4().hex[:12]}", + ) + backend: Final = facade.cache + assert isinstance(backend, AzureBlobCache) + try: + native_runtime(facade) + kwargs: Final = completion_kwargs("azure") + await facade.async_add_cache({"answer": "azure"}, **kwargs) + assert await facade.async_get_cache(**kwargs) == {"answer": "azure"} + assert backend.get_cache(facade.get_cache_key(**kwargs))["response"] == {"answer": "azure"} + finally: + backend.container_client.delete_container() + await backend.disconnect() + + +class _SemanticHit: + """A native semantic runtime that answers every lookup with one cached response.""" + + kind: Final = "native" + + def lookup_semantic(self, request: object) -> tuple[object, float | None]: + return {"answer": 42}, 0.97 + + async def async_lookup_semantic(self, request: object) -> tuple[object, float | None]: + return {"answer": 42}, 0.97 + + +@pytest.mark.parametrize("semantic_type", [LiteLLMCacheType.QDRANT_SEMANTIC, LiteLLMCacheType.REDIS_SEMANTIC]) +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +def test_native_semantic_hit_stamps_similarity_on_request_metadata( + semantic_type: LiteLLMCacheType, use_async: bool +) -> None: + """Python semantic backends write `metadata["semantic-similarity"]` on every lookup, and the + facade copies it to the caller's metadata; the native path must report it the same way.""" + facade: Final = Cache() + facade.type = semantic_type + facade._native_cache = ResponseCacheRuntime(cast(NativeResponseCacheRuntime, _SemanticHit())) # pyright: ignore[reportPrivateUsage] # the native path under test has no public setter + metadata: Final[dict[str, object]] = {} + kwargs: Final = { + "cache_key": "semantic-key", + "messages": [{"role": "user", "content": "hello"}], + "metadata": metadata, + } + + result: Final = asyncio.run(facade.async_get_cache(**kwargs)) if use_async else facade.get_cache(**kwargs) + + assert result == {"answer": 42} + assert metadata["semantic-similarity"] == 0.97 diff --git a/tests/test_litellm_rust/test_fork_guard.py b/tests/test_litellm_rust/test_fork_guard.py index 2a8fb6f9fca..02c454e0460 100644 --- a/tests/test_litellm_rust/test_fork_guard.py +++ b/tests/test_litellm_rust/test_fork_guard.py @@ -155,10 +155,17 @@ import asyncio import os import litellm from litellm.proxy.spend_tracking.input_tokens import count_input_tokens -from litellm.rust_bridge import _native +from litellm.rust_bridge import _native, catalog +from litellm.rust_bridge.catalog import Route, RouteRule +from litellm.rust_bridge.configuration import Rollout from litellm.litellm_core_utils.tokenizer import HuggingFaceTokenizer from litellm.utils import claude_json_str +catalog.RULES = ( + RouteRule(Route.TOKENIZER, Rollout.RUST_OPT_IN), + RouteRule(Route.TOKEN_COUNTER, Rollout.RUST_OPT_IN), + *catalog.RULES, +) litellm.anthropic_models = {*litellm.anthropic_models, "tokenizer-fork-fixture"} _native.reserve_process_for_forking() for create in ( diff --git a/tests/test_litellm_rust/test_valkey_semantic_cache_native.py b/tests/test_litellm_rust/test_valkey_semantic_cache_native.py index c87a9f86a80..046f3a70ae8 100644 --- a/tests/test_litellm_rust/test_valkey_semantic_cache_native.py +++ b/tests/test_litellm_rust/test_valkey_semantic_cache_native.py @@ -15,7 +15,10 @@ import redis from litellm.caching.caching import Cache from litellm.caching.valkey_semantic_cache import ValkeySemanticCache -from litellm.rust_bridge import _native +from litellm.rust_bridge import _native, catalog +from litellm.rust_bridge.catalog import CacheRule +from litellm.rust_bridge.configuration import Rollout +from litellm.rust_bridge.response_cache import ResponseCacheRuntime from litellm.types.caching import LiteLLMCacheType pytestmark: Final = pytest.mark.requires_rust_extension @@ -597,3 +600,22 @@ async def test_ping_maps_unsupported_native_operation_to_not_implemented( binding: Final = _native._CacheTestResolver(SimpleNamespace(cache=handle)).resolve() with pytest.raises(NotImplementedError): await binding.ping() + + +async def test_rust_required_rule_activates_the_facade_natively( + valkey_url: str, + index_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + catalog, + "RULES", + (CacheRule(Rollout.RUST_REQUIRED, backends=frozenset({LiteLLMCacheType.VALKEY_SEMANTIC})),), + ) + facade: Final = _facade(valkey_url, index_name, {"semantic cache prompt": [1.0, 0.0]}) + runtime: Final = facade._native_cache # pyright: ignore[reportPrivateUsage] # the activation under test has no public accessor + assert isinstance(runtime, ResponseCacheRuntime) + assert runtime.kind == "native" + kwargs: Final = {"model": "gpt-4o", "messages": _request()["messages"]} + await facade.async_add_cache({"answer": "valkey"}, **kwargs) + assert await facade.async_get_cache(**kwargs) == {"answer": "valkey"} diff --git a/tests/unified_google_tests/conftest.py b/tests/unified_google_tests/conftest.py index cd05c856faf..9eac5f49651 100644 --- a/tests/unified_google_tests/conftest.py +++ b/tests/unified_google_tests/conftest.py @@ -102,7 +102,7 @@ def google_genai_proxy_url() -> Iterator[str]: credentials_file = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") if not (credentials_file and os.path.isfile(credentials_file)): vertex_credentials_path = load_vertex_ai_credentials( - model="vertex_ai/gemini-2.5-flash-lite" + model="vertex_ai/gemini-3.5-flash-lite" ) if vertex_credentials_path: temp_credentials_path = vertex_credentials_path diff --git a/tests/unified_google_tests/google_genai_proxy_test_config.yaml b/tests/unified_google_tests/google_genai_proxy_test_config.yaml index 0a1779aa3ec..99f24e62916 100644 --- a/tests/unified_google_tests/google_genai_proxy_test_config.yaml +++ b/tests/unified_google_tests/google_genai_proxy_test_config.yaml @@ -1,12 +1,12 @@ model_list: - - model_name: gemini-2.5-flash-lite + - model_name: gemini-3.5-flash-lite litellm_params: - model: gemini/gemini-2.5-flash-lite + model: gemini/gemini-3.5-flash-lite api_key: os.environ/GEMINI_API_KEY - - model_name: vertex-gemini-2.5-flash-lite + - model_name: vertex-gemini-3.5-flash-lite litellm_params: - model: vertex_ai/gemini-2.5-flash-lite + model: vertex_ai/gemini-3.5-flash-lite vertex_location: global router_settings: diff --git a/tests/unified_google_tests/test_google_ai_studio.py b/tests/unified_google_tests/test_google_ai_studio.py index 6d4c3725080..2364a01cedb 100644 --- a/tests/unified_google_tests/test_google_ai_studio.py +++ b/tests/unified_google_tests/test_google_ai_studio.py @@ -13,12 +13,12 @@ class TestGoogleGenAIStudio(BaseGoogleGenAITest, BaseGoogleGenAIProxySDKTest): @property def model_config(self): return { - "model": "gemini/gemini-2.5-flash-lite", + "model": "gemini/gemini-3.5-flash-lite", } @property def proxy_model_name(self) -> str: - return "gemini-2.5-flash-lite" + return "gemini-3.5-flash-lite" @pytest.mark.asyncio @@ -94,7 +94,7 @@ async def test_mock_stream_generate_content_with_tools(): "\n--- Testing async agenerate_content_stream with function call parsing ---" ) response = await litellm.google_genai.agenerate_content_stream( - model="gemini/gemini-2.5-flash-lite", + model="gemini/gemini-3.5-flash-lite", contents=contents, tools=[ { @@ -343,7 +343,7 @@ async def test_validate_post_request_parameters(): # Make the API call response = await litellm.google_genai.agenerate_content_stream( - model="gemini/gemini-2.5-flash-lite", contents=contents, tools=tools + model="gemini/gemini-3.5-flash-lite", contents=contents, tools=tools ) # Consume the response to ensure the request is made @@ -387,11 +387,11 @@ async def test_validate_post_request_parameters(): # Validate model field assert "model" in request_data, "Expected 'model' field in request data" - # Model might be transformed, but should contain gemini-2.5-flash-lite + # Model might be transformed, but should contain gemini-3.5-flash-lite model_value = request_data["model"] assert ( - "gemini-2.5-flash-lite" in model_value - ), f"Expected model to contain 'gemini-2.5-flash-lite', got: {model_value}" + "gemini-3.5-flash-lite" in model_value + ), f"Expected model to contain 'gemini-3.5-flash-lite', got: {model_value}" print(f"✅ Model validation passed: {model_value}") # Validate contents field diff --git a/tests/unified_google_tests/test_google_genai_proxy_test_config.py b/tests/unified_google_tests/test_google_genai_proxy_test_config.py index 694ec336bac..272e589e94a 100644 --- a/tests/unified_google_tests/test_google_genai_proxy_test_config.py +++ b/tests/unified_google_tests/test_google_genai_proxy_test_config.py @@ -12,14 +12,13 @@ from typing_extensions import ReadOnly, TypedDict import litellm from litellm import Router from litellm.constants import INITIAL_RETRY_DELAY, MAX_RETRY_DELAY -from litellm.llms.vertex_ai.common_utils import get_vertex_base_url +from litellm.llms.vertex_ai.common_utils import _get_gemini_url, get_vertex_base_url from litellm.llms.vertex_ai.vertex_llm_base import VertexBase CONFIG_PATH: Final = Path(__file__).parent / "google_genai_proxy_test_config.yaml" -GEMINI_DEPLOYMENT: Final = "gemini-2.5-flash-lite" -VERTEX_DEPLOYMENT: Final = "vertex-gemini-2.5-flash-lite" -GEMINI_HOST: Final = "generativelanguage.googleapis.com" -GEMINI_GENERATE_CONTENT_PATH: Final = "/v1beta/models/gemini-2.5-flash-lite:generateContent" +GEMINI_DEPLOYMENT: Final = "gemini-3.5-flash-lite" +VERTEX_DEPLOYMENT: Final = "vertex-gemini-3.5-flash-lite" +GEMINI_GENERATE_CONTENT_URL: Final = _get_gemini_url(mode="chat", model=GEMINI_DEPLOYMENT, stream=False)[0] VERTEX_GLOBAL_BASE_URL: Final = "https://aiplatform.googleapis.com" RESOURCE_EXHAUSTED: Final = { "error": {"code": 429, "message": "Resource exhausted. Please try again later.", "status": "RESOURCE_EXHAUSTED"} @@ -81,7 +80,7 @@ async def test_ci_proxy_config_rides_out_consecutive_429s_with_backoff( ) -> None: monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) litellm.in_memory_llm_clients_cache.flush_cache() - route: Final = respx_mock.post(host=GEMINI_HOST, path=GEMINI_GENERATE_CONTENT_PATH).mock( + route: Final = respx_mock.post(GEMINI_GENERATE_CONTENT_URL).mock( side_effect=[httpx.Response(429, json=RESOURCE_EXHAUSTED)] * CONSECUTIVE_RATE_LIMITS + [httpx.Response(200, json=PONG)] ) diff --git a/tests/unified_google_tests/test_vertex_ai_native.py b/tests/unified_google_tests/test_vertex_ai_native.py index 640157bc33e..4bdc1a55283 100644 --- a/tests/unified_google_tests/test_vertex_ai_native.py +++ b/tests/unified_google_tests/test_vertex_ai_native.py @@ -8,9 +8,10 @@ class TestVertexAIGenerateContent(BaseGoogleGenAITest, BaseGoogleGenAIProxySDKTe @property def model_config(self): return { - "model": "vertex_ai/gemini-2.5-flash-lite", + "model": "vertex_ai/gemini-3.5-flash-lite", + "vertex_location": "global", } @property def proxy_model_name(self) -> str: - return "vertex-gemini-2.5-flash-lite" + return "vertex-gemini-3.5-flash-lite" diff --git a/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py index e4a33d5772c..47609261a25 100644 --- a/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py +++ b/tests/unit/llms/azure_ai/chat/test_azure_ai_transformation.py @@ -157,27 +157,6 @@ def test_foundry_gpt_6_astra_keeps_sampling_params_when_reasoning_effort_is_none assert optional_params == {"reasoning_effort": "none", "temperature": 0.2, "top_p": 0.9} -def test_a_gpt_5_name_without_a_foundry_row_keeps_reading_its_own_entry( - monkeypatch: pytest.MonkeyPatch, _local_model_cost_map -): - """Most gpt-5-family names have no azure_ai/ row. Reading an azure_ai/ key for those finds - nothing, and an openai.azure.com base sends the name down the azure provider, which has no key - for it either, so every effort answer would silently fall back to false and take temperature, - top_p and logprobs down with it.""" - monkeypatch.setenv("AZURE_AI_API_BASE", "https://example-resource.openai.azure.com") - monkeypatch.setenv("AZURE_AI_API_KEY", "placeholder") - - optional_params = litellm.utils.get_optional_params( - model="gpt-5.1-chat-latest", - custom_llm_provider="azure_ai", - temperature=0.2, - top_p=0.9, - logprobs=True, - ) - - assert optional_params["temperature"] == 0.2 - assert optional_params["top_p"] == 0.9 - assert optional_params["logprobs"] is True def test_azure_ai_grok_stop_parameter_handling(): diff --git a/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index 6815f00267c..3f740edf834 100644 --- a/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/unit/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -1695,21 +1695,6 @@ def test_nim_vllm_extras_translated_end_to_end_in_request_body(): assert request_body["top_k"] == 40 -def test_in_schema_unsupported_params_still_raise(): - with pytest.raises(litellm.UnsupportedParamsError): - litellm.get_optional_params( - model="accounts/fireworks/models/llama-v3-70b-instruct", - custom_llm_provider="fireworks_ai", - drop_params=False, - store=True, - ) - optional_params = litellm.get_optional_params( - model="accounts/fireworks/models/llama-v3-70b-instruct", - custom_llm_provider="fireworks_ai", - drop_params=True, - store=True, - ) - assert "store" not in optional_params def test_streaming_preserves_selected_model_for_private_accounting(): diff --git a/tests/unit/llms/moonshot/test_moonshot_chat_transformation.py b/tests/unit/llms/moonshot/test_moonshot_chat_transformation.py index c39affc18a8..4e7a2931cda 100644 --- a/tests/unit/llms/moonshot/test_moonshot_chat_transformation.py +++ b/tests/unit/llms/moonshot/test_moonshot_chat_transformation.py @@ -715,7 +715,7 @@ class TestMoonshotReasoningEffort: def force_local_model_cost(self, monkeypatch): monkeypatch.setattr(litellm, "model_cost", GetModelCostMap.load_local_model_cost_map()) - @pytest.mark.parametrize("model", ["kimi-k3", "kimi-k2.5", "kimi-k2.6", "kimi-k2-thinking"]) + @pytest.mark.parametrize("model", ["kimi-k3", "kimi-k2.5", "kimi-k2.6"]) def test_reasoning_model_supports_reasoning_effort(self, model): assert "reasoning_effort" in MoonshotChatConfig().get_supported_openai_params(model) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/router/useRouterFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/router/useRouterFields.ts index b5adb0994e6..bb37b559cb6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/router/useRouterFields.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/router/useRouterFields.ts @@ -16,6 +16,7 @@ export interface RouterSettingsField { export interface RouterFieldsResponse { fields: RouterSettingsField[]; routing_strategy_descriptions: Record; + routing_group_strategies?: string[]; } const routerFieldsKeys = createQueryKeys("routerFields"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.test.ts new file mode 100644 index 00000000000..e5f7814c7c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.test.ts @@ -0,0 +1,65 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getRouterSettingsCall, setCallbacksCall } from "@/components/networking"; +import type { RoutingGroup } from "@/components/routing_groups/types"; +import { useRoutingGroups, useSaveRoutingGroups } from "./useRoutingGroups"; + +vi.mock("@/components/networking", () => ({ + getRouterSettingsCall: vi.fn(), + setCallbacksCall: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "test-token", userId: "admin", userRole: "Admin" }), +})); + +const createWrapper = () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function QueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +}; + +describe("routing group settings", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + { metadata: ["simple-shuffle", "priority"], expected: ["simple-shuffle", "priority"] }, + { metadata: undefined, expected: ["simple-shuffle"] }, + ])("uses the advertised group strategies and supports older gateways", async ({ metadata, expected }) => { + vi.mocked(getRouterSettingsCall).mockResolvedValue({ + fields: [{ field_name: "routing_strategy", options: ["simple-shuffle"] }], + routing_group_strategies: metadata, + current_values: {}, + }); + const { result } = renderHook(() => useRoutingGroups(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.data?.availableStrategies).toEqual(expected)); + }); + + it("loads and saves membership priorities through the existing router settings endpoint", async () => { + const groups: RoutingGroup[] = [ + { + group_name: "preferred-chat", + models: ["preferred", "backup"], + routing_strategy: "priority", + model_priorities: { preferred: 1, backup: 4 }, + }, + ]; + vi.mocked(getRouterSettingsCall).mockResolvedValue({ current_values: { routing_groups: groups } }); + vi.mocked(setCallbacksCall).mockResolvedValue({}); + const { result } = renderHook(() => ({ query: useRoutingGroups(), save: useSaveRoutingGroups() }), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.query.data?.routingGroups).toEqual(groups)); + await act(async () => { + await result.current.save.mutateAsync(groups); + }); + + expect(setCallbacksCall).toHaveBeenCalledWith("test-token", { router_settings: { routing_groups: groups } }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.ts index 71d27d2017c..5ed6fa6a594 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/routingGroups/useRoutingGroups.ts @@ -19,11 +19,12 @@ const fetchRoutingGroups = async (accessToken: string): Promise f?.field_name === "routing_strategy"); + const groupStrategies: unknown = data?.routing_group_strategies ?? routingStrategyField?.options; return { routingGroups: Array.isArray(currentValues.routing_groups) ? currentValues.routing_groups : [], routingStrategy: currentValues.routing_strategy ?? null, - availableStrategies: Array.isArray(routingStrategyField?.options) ? routingStrategyField.options : [], + availableStrategies: Array.isArray(groupStrategies) ? groupStrategies : [], }; }; diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterAdvancedSections.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterAdvancedSections.tsx index 71f8bce76b5..44adecb9e1d 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterAdvancedSections.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterAdvancedSections.tsx @@ -141,7 +141,7 @@ const ComplexityRouterAdvancedSections: React.FCAdvanced: Reminder Markers, + label: Advanced: Ignore Custom Tags, children: , }, { diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index 56b37b92af3..756e505997c 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -101,7 +101,7 @@ describe("ComplexityRouterConfig", () => { expect(screen.getByText("Advanced: Heuristic Keyword Overrides")).toBeInTheDocument(); expect(screen.getByText("Advanced: Housekeeping Routing")).toBeInTheDocument(); - expect(screen.getByText("Advanced: Reminder Markers")).toBeInTheDocument(); + expect(screen.getByText("Advanced: Ignore Custom Tags")).toBeInTheDocument(); const capabilityValue = { ...defaultValue, classifier_type: "capability" as const }; rerender(); @@ -128,7 +128,7 @@ describe("ComplexityRouterConfig", () => { renderWithProviders( , ); - fireEvent.click(screen.getByText("Advanced: Reminder Markers")); + fireEvent.click(screen.getByText("Advanced: Ignore Custom Tags")); const validation = screen.queryByText(/needs both/i); if (showValidationErrors) { expect(validation).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/add_model/ReminderMarkers.tsx b/ui/litellm-dashboard/src/components/add_model/ReminderMarkers.tsx index c7f9ee48e0b..88901bb8645 100644 --- a/ui/litellm-dashboard/src/components/add_model/ReminderMarkers.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ReminderMarkers.tsx @@ -26,31 +26,32 @@ const ReminderMarkers: React.FC<{ return (

- Delimiter pairs that wrap harness-injected reminder blocks, which are stripped before classification. Setting - any pair replaces the built-in pairs, so list every pair your harness emits. Matching is case-insensitive and - values are saved lowercased. + Auto Router already ignores built-in reminder tags, such as <system-reminder>, when choosing + a model. Leave this empty to keep the defaults. Add custom opening and closing tags to ignore the text between + them when routing. Custom pairs replace the defaults, so include any built-in pairs you still need. The selected + model still receives the full message. Matching is case-insensitive.

{markers.map((marker, index) => (
update(index, { open: event.target.value })} />
update(index, { close: event.target.value })} /> @@ -58,7 +59,7 @@ const ReminderMarkers: React.FC<{ {showValidationErrors && error &&

{error}

}
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index 9b4f1980f62..13df40464f0 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -414,8 +414,8 @@ export const getReminderMarkersError = (pairs: ReminderMarkerPair[] | undefined) for (const [index, pair] of (pairs ?? []).entries()) { const open = pair.open.trim().toLowerCase(); const close = pair.close.trim().toLowerCase(); - if (!open || !close) return `Reminder marker pair ${index + 1} needs both an opening and a closing delimiter`; - if (open === close) return `Reminder marker pair ${index + 1} must use different opening and closing delimiters`; + if (!open || !close) return `Tag pair ${index + 1} needs both an opening and a closing tag`; + if (open === close) return `Tag pair ${index + 1} must use different opening and closing tags`; } return null; }; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx index aa6cf92ceb9..6d66152cee5 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.integration.test.tsx @@ -377,9 +377,9 @@ describe("EditAutoRouterModal advanced field round trips", () => { expect(screen.getByRole("switch", { name: "Route housekeeping calls to the cheapest tier" })).not.toBeChecked(); expect(screen.getByRole("combobox", { name: "e.g., conversation title" })).toHaveValue(""); - await user.click(screen.getByText("Advanced: Reminder Markers")); - expect(screen.getByLabelText("Opening delimiter")).toHaveValue(""); - expect(screen.getByLabelText("Closing delimiter")).toHaveValue(""); + await user.click(screen.getByText("Advanced: Ignore Custom Tags")); + expect(screen.getByLabelText("Opening tag")).toHaveValue(""); + expect(screen.getByLabelText("Closing tag")).toHaveValue(""); await user.click(screen.getByText("Advanced: Response Format")); const maxTokensSwitch = screen.getByRole("switch", { name: "Cap max_tokens at the tier model's output ceiling" }); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 82399792674..d358d23408c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2003,6 +2003,7 @@ interface UiSpendLogsParams { end_user?: string; status_filter?: string; cache_hit_filter?: string; + span_type?: string; /** Filter by model name (e.g. "gpt-4") */ model?: string; /** Filter by model ID (litellm model deployment id) */ diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.test.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.integration.test.tsx similarity index 67% rename from ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.test.tsx rename to ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.integration.test.tsx index e376c551923..4a9a05c1023 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.test.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.integration.test.tsx @@ -1,4 +1,5 @@ import userEvent from "@testing-library/user-event"; +import { fireEvent } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen } from "@/../tests/test-utils"; @@ -6,7 +7,7 @@ import { renderWithProviders, screen } from "@/../tests/test-utils"; import RoutingGroupModal from "./RoutingGroupModal"; import type { RoutingGroup } from "./types"; -const STRATEGIES = ["simple-shuffle", "latency-based-routing", "usage-based-routing"]; +const STRATEGIES = ["simple-shuffle", "latency-based-routing", "usage-based-routing", "priority"]; const MODEL_OPTIONS = ["gpt-4o", "claude-sonnet", "gemini-pro"]; const STRATEGY_DESCRIPTIONS = { "simple-shuffle": "Spreads requests evenly across the group." }; @@ -40,6 +41,14 @@ const STORED_GROUP_NULL_ARGS: RoutingGroup = { routing_strategy_args: null, }; +const STORED_PRIORITY_GROUP: RoutingGroup = { + group_name: "preferred-chat", + models: ["gpt-4o", "claude-sonnet"], + routing_strategy: "priority", + routing_strategy_args: null, + model_priorities: { "gpt-4o": 3, "claude-sonnet": 7 }, +}; + const EXPECTED_NULL_ARGS_PAYLOAD: RoutingGroup = { group_name: "already-taken", models: ["gpt-4o"], @@ -98,6 +107,138 @@ const save = async (user: ReturnType, name: string) => await user.click(screen.getByRole("button", { name })); describe("RoutingGroupModal", () => { + it("creates a priority group with editable defaults and members already used by another group", async () => { + const user = userEvent.setup(); + const { onSubmit } = renderModal({ groupNameByModel: { "gpt-4o": "legacy-group" } }); + + fireEvent.change(screen.getByLabelText("Group Name"), { target: { value: "preferred-chat" } }); + await pickModels(user, "gpt-4o", "claude-sonnet"); + await pickStrategy(user, "Priority"); + + expect(screen.getByLabelText("Priority for gpt-4o")).toHaveValue("1"); + expect(screen.getByLabelText("Priority for claude-sonnet")).toHaveValue("2"); + fireEvent.change(screen.getByLabelText("Priority for claude-sonnet"), { target: { value: "1" } }); + await save(user, "Create Group"); + + const expected: RoutingGroup = { + group_name: "preferred-chat", + models: ["gpt-4o", "claude-sonnet"], + routing_strategy: "priority", + routing_strategy_args: null, + model_priorities: { "gpt-4o": 1, "claude-sonnet": 1 }, + }; + expect(onSubmit).toHaveBeenCalledWith(expected); + }); + + it("preserves explicit priorities through edit for model names that are dictionary keys", async () => { + const user = userEvent.setup(); + const stored: RoutingGroup = { + group_name: "preferred-chat", + models: ["provider/model.v1", "constructor", "__proto__"], + routing_strategy: "priority", + routing_strategy_args: null, + model_priorities: Object.fromEntries([ + ["__proto__", 3], + ["constructor", 8], + ["provider/model.v1", 2], + ]), + }; + const { onSubmit } = renderModal({ mode: "edit", initialValue: stored, modelOptions: stored.models }); + + expect(screen.getByLabelText("Priority for provider/model.v1")).toHaveValue("2"); + expect(screen.getByLabelText("Priority for constructor")).toHaveValue("8"); + await save(user, "Save Changes"); + + expect(onSubmit).toHaveBeenCalledWith(stored); + }); + + it.each([ + { catalog: "omits Priority", strategies: ["simple-shuffle"] }, + { catalog: "includes Priority", strategies: STRATEGIES }, + ])("keeps unsaved priorities when switching away and back while the catalog $catalog", async ({ strategies }) => { + const user = userEvent.setup(); + const { onSubmit } = renderModal({ + mode: "edit", + initialValue: STORED_PRIORITY_GROUP, + availableStrategies: strategies, + }); + + fireEvent.change(screen.getByLabelText("Priority for gpt-4o"), { target: { value: "9" } }); + await pickStrategy(user, "simple-shuffle"); + expect(screen.queryByLabelText("Priority for gpt-4o")).not.toBeInTheDocument(); + await pickStrategy(user, "Priority"); + expect(screen.getByLabelText("Priority for gpt-4o")).toHaveValue("9"); + expect(screen.getByLabelText("Priority for claude-sonnet")).toHaveValue("7"); + await save(user, "Save Changes"); + + expect(onSubmit).toHaveBeenCalledWith({ + ...STORED_PRIORITY_GROUP, + model_priorities: { "gpt-4o": 9, "claude-sonnet": 7 }, + }); + }); + + it("offers only the backend catalog when creating a group", async () => { + const user = userEvent.setup(); + renderModal({ initialValue: STORED_PRIORITY_GROUP, availableStrategies: ["simple-shuffle"] }); + + await user.click(screen.getByLabelText("Routing Strategy")); + + expect(await screen.findByRole("option", { name: "simple-shuffle" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Priority" })).not.toBeInTheDocument(); + }); + + it("shows invalid stored priorities and lets the user repair them before saving", async () => { + const user = userEvent.setup(); + const { onSubmit } = renderModal({ + mode: "edit", + initialValue: { + group_name: "preferred-chat", + models: ["gpt-4o"], + routing_strategy: "priority", + model_priorities: { "gpt-4o": 0, unused: 9 }, + }, + }); + + expect(screen.getByLabelText("Priority for gpt-4o")).toHaveValue("0"); + expect(screen.getByText("Model is not selected")).toBeInTheDocument(); + await save(user, "Save Changes"); + expect(onSubmit).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Remove priority for unused" })); + await save(user, "Save Changes"); + expect(await screen.findByText("Priorities must be whole numbers from 1 to 9007199254740991")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Priority for gpt-4o"), { target: { value: "4" } }); + await save(user, "Save Changes"); + + const expected: RoutingGroup = { + group_name: "preferred-chat", + models: ["gpt-4o"], + routing_strategy: "priority", + routing_strategy_args: null, + model_priorities: { "gpt-4o": 4 }, + }; + expect(onSubmit).toHaveBeenCalledWith(expected); + }); + + it("restores the legacy ownership restriction when switching away from priority", async () => { + const user = userEvent.setup(); + const { onSubmit } = renderModal({ + mode: "edit", + initialValue: { + group_name: "preferred-chat", + models: ["gpt-4o"], + routing_strategy: "priority", + model_priorities: { "gpt-4o": 1 }, + }, + groupNameByModel: { "gpt-4o": "legacy-group" }, + }); + + await pickStrategy(user, "simple-shuffle"); + await save(user, "Save Changes"); + + expect(await screen.findByText(/Already claimed: gpt-4o/)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it("submits an untouched edit of a group whose stored arguments are null", async () => { const user = userEvent.setup(); const { onSubmit } = renderModal({ mode: "edit", initialValue: STORED_GROUP_NULL_ARGS }); diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx index 1057cc6ca16..01bdbae4bf2 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupModal.tsx @@ -26,6 +26,7 @@ import { STRATEGIES_WITH_ARGS, argsForStrategy, buildRoutingGroupPayload, + prioritiesForModels, toRoutingGroupFormValues, } from "./routingGroupPayload"; import type { RoutingGroup } from "./types"; @@ -65,7 +66,14 @@ const RoutingGroupModal: React.FC = ({ saving, }) => { const modelsAnchor = useComboboxAnchor(); - const strategyItems = availableStrategies.map((strategy) => ({ label: strategy, value: strategy })); + const selectableStrategies = + mode === "edit" && initialValue + ? Array.from(new Set([...availableStrategies, initialValue.routing_strategy])) + : availableStrategies; + const strategyItems = selectableStrategies.map((strategy) => ({ + label: strategy === "priority" ? "Priority" : strategy, + value: strategy, + })); const reservedNames = useMemo(() => { const others = existingGroupNames.filter((n) => n !== initialValue?.group_name); @@ -80,19 +88,18 @@ const RoutingGroupModal: React.FC = ({ .min(1, "Group name is required") .max(GROUP_NAME_MAX_LENGTH, `Must be ${GROUP_NAME_MAX_LENGTH} characters or fewer`) .refine((value) => !reservedNames.has(value.toLowerCase()), "A group with this name already exists"), - models: z - .array(z.string()) - .min(1, "Select at least one model") - .superRefine((models, ctx) => { - const conflict = modelConflictError(models, groupNameByModel); - if (conflict !== null) { - ctx.addIssue({ code: "custom", message: conflict }); - } - }), + models: z.array(z.string()).min(1, "Select at least one model"), routing_strategy: z.string().min(1, "Strategy is required"), routing_strategy_args: z.string(), + model_priorities: z.array(z.object({ model: z.string(), priority: z.string() })), }; - return z.object(shape); + return z.object(shape).superRefine((values, ctx) => { + if (values.routing_strategy === "priority") return; + const conflict = modelConflictError(values.models, groupNameByModel); + if (conflict !== null) { + ctx.addIssue({ code: "custom", message: conflict, path: ["models"] }); + } + }); }, [reservedNames, groupNameByModel]); const form = useZodForm(schema, { defaultValues: toRoutingGroupFormValues(initialValue, availableStrategies) }); @@ -102,11 +109,12 @@ const RoutingGroupModal: React.FC = ({ }, [open, initialValue, availableStrategies, form]); const selectedStrategy = useWatch({ control: form.control, name: "routing_strategy" }); + const selectedModels = useWatch({ control: form.control, name: "models" }); const handleSubmit = async (values: z.infer) => { const payload = buildRoutingGroupPayload(values); if (!payload.ok) { - form.setError("routing_strategy_args", { message: payload.argsError }); + form.setError(payload.field, { message: payload.message }); return; } await onSubmit(payload.group); @@ -126,7 +134,7 @@ const RoutingGroupModal: React.FC = ({ control={form.control} name="group_name" label="Group Name" - description="Use this name as the model in API calls — LiteLLM routes the request to one of the group's models." + description="Use this name as the model in API calls. LiteLLM routes the request to one of the group's models." > {({ ref, ...field }) => } @@ -135,10 +143,22 @@ const RoutingGroupModal: React.FC = ({ control={form.control} name="models" label="Models" - description="Models from your model list that this group routes between. A model can only be in one group." + description={ + selectedStrategy === "priority" + ? "Models from your model list that this group routes between. Models can belong to multiple priority groups." + : "Models from your model list that this group routes between. A model can belong to one non-priority group." + } > {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( - + { + onChange(models); + form.setValue("model_priorities", prioritiesForModels(models, form.getValues("model_priorities"))); + }} + > }> {(selected: string[]) => ( @@ -176,7 +196,11 @@ const RoutingGroupModal: React.FC = ({ control={form.control} name="routing_strategy" label="Routing Strategy" - description={strategyDescriptions[selectedStrategy]} + description={ + selectedStrategy === "priority" + ? "Lower priorities are tried first. Models with the same priority share traffic. Applies only when calling this group." + : strategyDescriptions[selectedStrategy] + } > {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + onChange( + value.map((item, itemIndex) => + itemIndex === index ? { ...item, priority: event.target.value } : item, + ), + ) + } + /> + {!selectedModels.includes(entry.model) && ( + + )} +
+ ))} +
+ )} + + )} + {STRATEGIES_WITH_ARGS.has(selectedStrategy) && ( = ({ )}

- Models not claimed by an explicit group fall through to the proxy's top-level routing strategy. + {selectedStrategy === "priority" + ? "Direct requests to a member model keep their existing routing behavior." + : "Models outside non-priority groups use the proxy's top-level routing strategy."}

diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx index fafea569012..85dd67099d6 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupUsagePanel.tsx @@ -14,7 +14,8 @@ interface RoutingGroupUsagePanelProps { baseUrl: string; } -const exampleModel = (group: RoutingGroup): string => group.models[0] ?? ""; +const exampleModel = (group: RoutingGroup): string => + group.routing_strategy === "priority" ? group.group_name : group.models[0] ?? ""; const buildCurlSnippet = (group: RoutingGroup, baseUrl: string): string => `curl -X POST '${baseUrl}/v1/chat/completions' \\ @@ -69,8 +70,17 @@ export function RoutingGroupUsagePanel({ group, baseUrl }: RoutingGroupUsagePane How routing works for this group

- Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "} - {formatStrategyLabel(group.routing_strategy)} strategy. + {group.routing_strategy === "priority" ? ( + <> + Request {group.group_name} to try eligible models in + priority order. Direct requests to a member model keep their existing routing behavior. + + ) : ( + <> + Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "} + {formatStrategyLabel(group.routing_strategy)} strategy. + + )}

diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.integration.test.tsx similarity index 86% rename from ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx rename to ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.integration.test.tsx index 6f14b76e2fd..6664ecab623 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.integration.test.tsx @@ -113,6 +113,22 @@ describe("RoutingGroupsTable", () => { expect(panel?.textContent).toContain("gpt-4o"); }); + it("uses the callable group name in every priority routing example", async () => { + const user = userEvent.setup(); + render(); + expect(within(rowFor("prod-group")).getByText("Priority")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "prod-group" })); + + expect(screen.getByRole("tabpanel")).toHaveTextContent('"model": "prod-group"'); + await user.click(screen.getByRole("tab", { name: "Python (OpenAI SDK)" })); + expect(screen.getByRole("tabpanel")).toHaveTextContent('model="prod-group"'); + await user.click(screen.getByRole("tab", { name: "JavaScript (OpenAI SDK)" })); + expect(screen.getByRole("tabpanel")).toHaveTextContent('model: "prod-group"'); + expect( + screen.getByText(/Direct requests to a member model keep their existing routing behavior/), + ).toBeInTheDocument(); + }); + it("should expand only the clicked group", async () => { const user = userEvent.setup(); render(); diff --git a/ui/litellm-dashboard/src/components/routing_groups/index.tsx b/ui/litellm-dashboard/src/components/routing_groups/index.tsx index 17329d0b572..ae2d62ae20f 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/index.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/index.tsx @@ -46,6 +46,7 @@ const RoutingGroups: React.FC = () => { const availableStrategies = useMemo(() => { if (data?.availableStrategies?.length) return data.availableStrategies; + if (routerFields?.routing_group_strategies?.length) return routerFields.routing_group_strategies; const fromFields = routerFields?.fields?.find((f) => f.field_name === "routing_strategy")?.options; return fromFields ?? []; }, [data?.availableStrategies, routerFields]); @@ -178,8 +179,17 @@ const RoutingGroups: React.FC = () => { Delete routing group?

- Models in {deletingGroup?.group_name} will fall back to the - proxy's top-level routing strategy. This cannot be undone. + {deletingGroup?.routing_strategy === "priority" ? ( + <> + Calls to {deletingGroup.group_name} will stop working. Direct + requests to its member models keep their existing routing behavior. This cannot be undone. + + ) : ( + <> + Models in {deletingGroup?.group_name} will fall back to the + proxy's top-level routing strategy. This cannot be undone. + + )}