diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f6c06f7d83f..158110cbbab 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,3 +2,4 @@ /litellm/model_prices_and_context_window_backup.json @mateo-berri @ryan-crabbe-berri @kerry-berri @joshua-berri /helm/ @yassin-berriai /terraform/ @yassin-berriai +/.github/workflows/zizmor.yml @yuneng-berri diff --git a/.github/workflows/cost-map-guard.yml b/.github/workflows/cost-map-guard.yml index f9172b9551a..05b22017cbb 100644 --- a/.github/workflows/cost-map-guard.yml +++ b/.github/workflows/cost-map-guard.yml @@ -1,7 +1,7 @@ name: Cost map guard -on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the PR's cost map files are read as data and never executed - pull_request_target: +on: + pull_request: branches: - main - "litellm_**" @@ -19,6 +19,7 @@ jobs: steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: + ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - name: Fetch the pull request head and its merge base id: revisions diff --git a/.github/workflows/spend-logs-guard.yml b/.github/workflows/spend-logs-guard.yml index 29e9f236202..cc3bda9b880 100644 --- a/.github/workflows/spend-logs-guard.yml +++ b/.github/workflows/spend-logs-guard.yml @@ -1,7 +1,7 @@ name: Spend logs guard -on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the PR's diff is read as data and never executed - pull_request_target: +on: + pull_request: branches: - main @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: - ref: ${{ github.event.pull_request.base.ref }} + ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - name: Fetch the pull request head, its merge base and its reviews id: revisions diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 73f9efb8df9..eba23e795f3 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -27,10 +27,15 @@ jobs: with: persist-credentials: false + - name: Install zizmor + run: | + curl -fsSL --retry 3 -o "$RUNNER_TEMP/zizmor.tar.gz" \ + https://github.com/zizmorcore/zizmor/releases/download/v1.31.1/zizmor-x86_64-unknown-linux-gnu.tar.gz + echo "d185f726bdaf92df78273217dfdd24ebcd6b0ef48cf3ce776ff5762b6e138c99 $RUNNER_TEMP/zizmor.tar.gz" | sha256sum -c - + tar xzf "$RUNNER_TEMP/zizmor.tar.gz" -C "$RUNNER_TEMP" zizmor + - name: Run zizmor - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 - with: - version: "1.24.1" - min-severity: medium - advanced-security: false - annotations: true + env: + GH_TOKEN: ${{ github.token }} + run: | + "$RUNNER_TEMP/zizmor" --no-ignores --no-config --min-severity=medium --format=github . diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 10e590b5006..66a345fc438 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -4387,6 +4387,7 @@ name = "litellm-router" version = "0.1.0" dependencies = [ "litellm-config", + "litellm-core-utils", "litellm-inference", "litellm-inference-messages", "litellm-router-types", diff --git a/litellm-rust/crates/core-utils/src/serde_compat.rs b/litellm-rust/crates/core-utils/src/serde_compat.rs index 3e4d82d3a3e..6f40a4af60a 100644 --- a/litellm-rust/crates/core-utils/src/serde_compat.rs +++ b/litellm-rust/crates/core-utils/src/serde_compat.rs @@ -1,13 +1,25 @@ -pub fn parse_str_bool(value: &str) -> Option { - let token = value.trim_matches(|character: char| { +fn python_strip(value: &str) -> &str { + value.trim_matches(|character: char| { character.is_whitespace() || matches!(character, '\u{1c}'..='\u{1f}') - }); + }) +} + +pub fn parse_str_bool(value: &str) -> Option { + let token = python_strip(value); if token.eq_ignore_ascii_case("true") { return Some(true); } token.eq_ignore_ascii_case("false").then_some(false) } +pub fn parse_pydantic_bool(value: &str) -> Option { + match python_strip(value).to_ascii_lowercase().as_str() { + "1" | "on" | "t" | "true" | "y" | "yes" => Some(true), + "0" | "off" | "f" | "false" | "n" | "no" => Some(false), + _ => None, + } +} + /// `redis-py` string Booleans: only `1`, `true`, and `yes` (case-insensitive) are true. pub fn parse_redis_bool(value: &str) -> bool { value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") @@ -34,4 +46,27 @@ mod tests { ) { assert_eq!(parse_str_bool(input), expected, "{input:?}"); } + + #[rstest] + #[case::yes(" YES ", Some(true))] + #[case::on("On", Some(true))] + #[case::one("1", Some(true))] + #[case::t("t", Some(true))] + #[case::y("y", Some(true))] + #[case::control_whitespace_true("\u{1c}TRUE\u{1f}", Some(true))] + #[case::no("No", Some(false))] + #[case::off("OFF", Some(false))] + #[case::zero("0", Some(false))] + #[case::f("F", Some(false))] + #[case::n("n", Some(false))] + #[case::false_word("false", Some(false))] + #[case::two("2", None)] + #[case::empty("", None)] + #[case::unknown("maybe", None)] + fn boolean_words_follow_pydantic_lax_bool_parsing( + #[case] input: &str, + #[case] expected: Option, + ) { + assert_eq!(parse_pydantic_bool(input), expected, "{input:?}"); + } } diff --git a/litellm-rust/crates/gateway/src/lib.rs b/litellm-rust/crates/gateway/src/lib.rs index b8ff8e46033..12a9bf9110a 100644 --- a/litellm-rust/crates/gateway/src/lib.rs +++ b/litellm-rust/crates/gateway/src/lib.rs @@ -37,6 +37,7 @@ pub fn build_inference(config: &Config) -> Result, Error> { .map(|value| value.expose().to_owned()) .or_else(|| std::env::var(name).ok()) }; + let router = ModelRouter::from_config(config, &lookup); let settings = &config.litellm_settings; let http_settings = HttpSettings::from_layers([ HttpSettingsLayer::from_environment(&lookup), @@ -64,12 +65,7 @@ pub fn build_inference(config: &Config) -> Result, Error> { Arc::new(EnvironmentSecrets::python_compatible(client)), )); let resources = CoreResources::new(pool); - Ok(Arc::new(Gateway::new( - resources, - http, - secrets, - ModelRouter::from_model_list(&config.model_list), - )?)) + Ok(Arc::new(Gateway::new(resources, http, secrets, router)?)) } pub async fn build_redis(config: &Config) -> Result { diff --git a/litellm-rust/crates/inference-messages/src/handler.rs b/litellm-rust/crates/inference-messages/src/handler.rs index fa802d20587..9d0bf09197e 100644 --- a/litellm-rust/crates/inference-messages/src/handler.rs +++ b/litellm-rust/crates/inference-messages/src/handler.rs @@ -141,9 +141,13 @@ async fn provider_call( &env_lookup, )?; - let body = config.transform_anthropic_messages_request( - trimmed, - &MessagesTransformContext::new(shaping.capabilities, shaping.settings.drop_params), + let body = config.request_body( + config.transform_anthropic_messages_request( + trimmed, + &MessagesTransformContext::new(shaping.capabilities, shaping.settings.drop_params), + )?, + &validated.headers, + shaping.settings.drop_params, )?; let environment = ValidatedEnvironment { headers: config.request_headers( @@ -514,6 +518,84 @@ mod tests { ); } + fn vertex_call_requesting( + shaping: MessagesShaping, + requested: &str, + drop_params: bool, + ) -> MessagesCall { + MessagesCall { + body: body( + json!({"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 16}), + ), + api_key: None, + api_base: Some("https://vertex.test".into()), + custom_llm_provider: Some("vertex_ai".into()), + litellm_params: Default::default(), + extra_headers: Some(Map::from_iter([ + ("authorization".to_string(), json!("Bearer vertex-token")), + ("anthropic-version".to_string(), json!(requested)), + ])), + provider_specific_header: None, + timeout: None, + shaping: MessagesShaping { + settings: MessagesSettings { + drop_params, + ..shaping.settings + }, + ..shaping + }, + } + } + + fn vertex_project(name: &str) -> Option { + (name == "VERTEXAI_PROJECT").then(|| "version-project".to_string()) + } + + #[rstest] + #[case::sdk_2("2026-10-21", false, "2026-10-21")] + #[case::sdk_1("2023-06-01", false, "vertex-2023-10-16")] + #[case::unknown_under_drop_params("latest", true, "vertex-2023-10-16")] + #[tokio::test] + async fn vertex_gets_the_requested_version_in_the_body_alone( + shaping: MessagesShaping, + #[case] requested: &str, + #[case] drop_params: bool, + #[case] body_version: &str, + ) { + let call = provider_call_with_secrets( + vertex_call_requesting(shaping, requested, drop_params), + &vertex_project, + ) + .await + .unwrap(); + let version_headers: Vec<&str> = call + .wire + .headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("anthropic-version")) + .map(|(_, value)| value.as_str()) + .collect(); + assert_eq!( + (version_headers, &call.wire.body["anthropic_version"]), + (Vec::<&str>::new(), &json!(body_version)) + ); + } + + #[rstest] + #[tokio::test] + async fn vertex_refuses_an_unsupported_version_without_drop_params(shaping: MessagesShaping) { + assert!(matches!( + provider_call_with_secrets( + vertex_call_requesting(shaping, "latest", false), + &vertex_project + ) + .await, + Err(Error::Llm(litellm_llms::Error::InvalidRequest( + litellm_llms::ErrorDetail::UnsupportedParameter { .. } + ))) + )); + } + #[rstest] #[tokio::test] async fn missing_credentials_are_reported_before_the_request_is_transformed( diff --git a/litellm-rust/crates/llms/src/base_llm/messages/transformation.rs b/litellm-rust/crates/llms/src/base_llm/messages/transformation.rs index 8a88af40d6b..744ce89afdb 100644 --- a/litellm-rust/crates/llms/src/base_llm/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/base_llm/messages/transformation.rs @@ -77,6 +77,15 @@ pub trait BaseMessagesConfig: Sync { headers } + fn request_body( + &self, + request: MessagesRequest, + _headers: &Headers, + _drop_params: bool, + ) -> Result { + Ok(request) + } + /// The JSON that goes on the wire, for a host whose body differs from the typed request: /// Python's configs `pop("model")` when the model is addressed by the URL. fn wire_body(&self, body: Value) -> Value { diff --git a/litellm-rust/crates/llms/src/vertex_ai/messages/AGENTS.md b/litellm-rust/crates/llms/src/vertex_ai/messages/AGENTS.md index b5d637d6bc7..d95da65ac9d 100644 --- a/litellm-rust/crates/llms/src/vertex_ai/messages/AGENTS.md +++ b/litellm-rust/crates/llms/src/vertex_ai/messages/AGENTS.md @@ -1,6 +1,7 @@ # rules -- Claude on Vertex AI: reuses `anthropic/messages` shaping, moves the model into the publisher URL, puts the Vertex `anthropic_version` in the body, and authenticates with a Google access token +- Claude on Vertex AI: reuses `anthropic/messages` shaping, moves the model into the publisher URL, and authenticates with a Google access token +- Vertex reads the API version only from the body's `anthropic_version` and answers 400 when an `anthropic-version` header names a different one, so the caller's header never reaches Vertex. A version Vertex accepts in the body is carried there, `2023-06-01`, `2023-01-01`, or no header get `vertex-2023-10-16`, and any other version drops under `drop_params` and is refused without it - Applies Vertex's beta policy and strips `output_config.effort` for models that do not accept it # references diff --git a/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs b/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs index 2e77dbc1ca8..46e5a68dcb8 100644 --- a/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/vertex_ai/messages/transformation.rs @@ -3,10 +3,10 @@ use litellm_auth_gcp::{ VertexConfig, get_vertex_ai_location, get_vertex_ai_project, get_vertex_ai_project_from_credentials, secret_names, }; -use litellm_http::request::has_header; +use litellm_http::request::{has_header, header_value, without_headers}; use litellm_llms_types::{ formats::messages::{MessagesOptionalParams, MessagesRequest, MessagesTool, OutputConfig}, - providers::anthropic::{AnthropicBeta, BetaProvider, BetaSet}, + providers::anthropic::{AnthropicBeta, BetaProvider, BetaSet, VERSION_HEADER}, recognized::Recognized, }; use litellm_router_types::LitellmParams; @@ -36,6 +36,8 @@ use litellm_llms_types::providers::vertex_ai::{ }; const WEB_SEARCH_TOOL_PREFIX: &str = "web_search"; +const VERSIONS_SERVED_AS_VERTEX_DEFAULT: [&str; 3] = ["", "2023-01-01", "2023-06-01"]; +const VERSIONS_VERTEX_ACCEPTS_IN_BODY: [&str; 2] = [ANTHROPIC_VERSION, "2026-10-21"]; /// Claude on Vertex AI, Python's `VertexAIPartnerModelsAnthropicMessagesConfig`: the Anthropic /// payload addressed to a project and location, authenticated with a Google access token. @@ -90,12 +92,6 @@ impl BaseMessagesConfig for VertexAiPartnerModelsAnthropicMessagesConfig { .params .output_config .and_then(|config| sanitize_output_config(config, accepts_effort)), - extra: request - .params - .extra - .into_iter() - .chain([("anthropic_version".to_string(), json!(ANTHROPIC_VERSION))]) - .collect(), ..request.params }, ..request @@ -133,7 +129,35 @@ impl BaseMessagesConfig for VertexAiPartnerModelsAnthropicMessagesConfig { } fn request_headers(&self, headers: Headers, request: &MessagesRequest) -> Headers { - merge_beta_headers(headers, vertex_feature_betas(request)) + merge_beta_headers( + without_headers(headers, &[VERSION_HEADER]), + vertex_feature_betas(request), + ) + } + + fn request_body( + &self, + request: MessagesRequest, + headers: &Headers, + drop_params: bool, + ) -> Result { + let version = vertex_version( + &request.model, + header_value(headers, VERSION_HEADER), + drop_params, + )?; + Ok(MessagesRequest { + params: MessagesOptionalParams { + extra: request + .params + .extra + .into_iter() + .chain([("anthropic_version".to_string(), json!(version))]) + .collect(), + ..request.params + }, + ..request + }) } /// Vertex addresses the model in the URL and rejects it in the body. @@ -225,6 +249,26 @@ fn vertex_feature_betas(request: &MessagesRequest) -> BetaSet { ) } +fn vertex_version<'a>( + model: &str, + requested: Option<&'a str>, + drop_params: bool, +) -> Result<&'a str, Error> { + match requested.map(str::trim).unwrap_or_default() { + version if VERSIONS_SERVED_AS_VERTEX_DEFAULT.contains(&version) => Ok(ANTHROPIC_VERSION), + version if VERSIONS_VERTEX_ACCEPTS_IN_BODY.contains(&version) => Ok(version), + _ if drop_params => Ok(ANTHROPIC_VERSION), + version => Err(Error::InvalidRequest( + crate::ErrorDetail::UnsupportedParameter { + model: model.into(), + param: VERSION_HEADER.into(), + value: version.into(), + hint: String::new(), + }, + )), + } +} + #[cfg(test)] mod tests { use rstest::rstest; @@ -471,6 +515,28 @@ mod tests { ); } + #[rstest] + #[case::sdk_spelling("anthropic-version")] + #[case::any_case("Anthropic-Version")] + fn a_forwarded_anthropic_version_never_reaches_vertex(#[case] name: &str) { + let request: MessagesRequest = serde_json::from_value(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}], + })) + .unwrap(); + assert_eq!( + VERTEX_ANTHROPIC_MESSAGES_CONFIG.request_headers( + vec![ + (name.to_string(), "2026-10-21".to_string()), + ("x-request-id".to_string(), "req-1".to_string()), + ], + &request, + ), + vec![("x-request-id".to_string(), "req-1".to_string())] + ); + } + fn transformed(value: Value, context: &MessagesTransformContext) -> Value { let request: MessagesRequest = serde_json::from_value(value).unwrap(); serde_json::to_value( @@ -482,16 +548,87 @@ mod tests { } #[rstest] - fn the_body_carries_the_vertex_version_and_the_wire_drops_the_model() { - let body = transformed( - json!({ - "model": "claude-sonnet-4-5@20250929", - "max_tokens": 16, - "messages": [{"role": "user", "content": "hi"}] - }), - &MessagesTransformContext::default(), + #[case::sdk_2(&[("anthropic-version", "2026-10-21")], false, "2026-10-21")] + #[case::any_case(&[("Anthropic-Version", "2026-10-21")], false, "2026-10-21")] + #[case::vertex_name(&[("anthropic-version", "vertex-2023-10-16")], false, "vertex-2023-10-16")] + #[case::sdk_1(&[("anthropic-version", "2023-06-01")], false, "vertex-2023-10-16")] + #[case::first_version(&[("anthropic-version", "2023-01-01")], false, "vertex-2023-10-16")] + #[case::blank(&[("anthropic-version", " ")], false, "vertex-2023-10-16")] + #[case::no_header(&[], false, "vertex-2023-10-16")] + #[case::unknown_under_drop_params(&[("anthropic-version", "latest")], true, "vertex-2023-10-16")] + fn the_requested_version_goes_in_the_body( + #[case] forwarded: &[(&str, &str)], + #[case] drop_params: bool, + #[case] body_version: &str, + ) { + let request: MessagesRequest = serde_json::from_value(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}], + })) + .unwrap(); + let headers: Headers = forwarded + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + let body = serde_json::to_value( + VERTEX_ANTHROPIC_MESSAGES_CONFIG + .request_body(request, &headers, drop_params) + .unwrap(), + ) + .unwrap(); + assert_eq!(body["anthropic_version"], json!(body_version)); + } + + #[rstest] + #[case::unreleased("2025-01-01")] + #[case::alias("latest")] + fn an_unsupported_version_is_refused_without_drop_params(#[case] requested: &str) { + let request: MessagesRequest = serde_json::from_value(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}], + })) + .unwrap(); + let headers: Headers = vec![("anthropic-version".to_string(), requested.to_string())]; + let Error::InvalidRequest(detail) = VERTEX_ANTHROPIC_MESSAGES_CONFIG + .request_body(request, &headers, false) + .unwrap_err() + else { + panic!("expected an invalid request"); + }; + assert_eq!( + detail.to_string(), + format!( + "claude-sonnet-4-5 does not support anthropic-version={requested}. \ + To drop unsupported params, set `litellm.drop_params = True`." + ) ); - assert_eq!(body["anthropic_version"], json!("vertex-2023-10-16")); + } + + #[rstest] + fn the_body_carries_the_vertex_version_and_the_wire_drops_the_model() { + let request: MessagesRequest = serde_json::from_value(json!({ + "model": "claude-sonnet-4-5@20250929", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + })) + .unwrap(); + let body = serde_json::to_value( + VERTEX_ANTHROPIC_MESSAGES_CONFIG + .request_body( + VERTEX_ANTHROPIC_MESSAGES_CONFIG + .transform_anthropic_messages_request( + request, + &MessagesTransformContext::default(), + ) + .unwrap(), + &Vec::new(), + false, + ) + .unwrap(), + ) + .unwrap(); assert_eq!(body["model"], json!("claude-sonnet-4-5@20250929")); let wire = VERTEX_ANTHROPIC_MESSAGES_CONFIG.wire_body(body); assert_eq!(wire.get("model"), None); diff --git a/litellm-rust/crates/router/Cargo.toml b/litellm-rust/crates/router/Cargo.toml index a356bedcf16..988d1b68805 100644 --- a/litellm-rust/crates/router/Cargo.toml +++ b/litellm-rust/crates/router/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true [dependencies] litellm-config.workspace = true +litellm-core-utils.workspace = true litellm-inference.workspace = true litellm-inference-messages.workspace = true litellm-router-types.workspace = true diff --git a/litellm-rust/crates/router/README.md b/litellm-rust/crates/router/README.md index 972d37d14be..a734e634104 100644 --- a/litellm-rust/crates/router/README.md +++ b/litellm-rust/crates/router/README.md @@ -1,4 +1,4 @@ -`litellm-router` scaffolds the model-list setup and deployment lookup portion of Python's `litellm.Router`. `Router::from_model_list(&config.model_list)` maps configured public names to provider deployments. Programmatic callers can collect `(String, Deployment)` entries into a `Router` +`litellm-router` scaffolds the model-list setup and deployment lookup portion of Python's `litellm.Router`. `Router::from_config(&config, &env)` maps configured public names to provider deployments and reads `drop_params` from `litellm_settings` and each deployment, resolving an `os.environ/NAME` value through `env`. Programmatic callers can collect `(String, Deployment)` entries into a `Router` Lookup is exact and returns `None` for an unknown name. This extraction preserves the gateway's existing behavior: the last entry wins when public names repeat. Multiple deployments per model group, routing strategies, retries, cooldowns, and fallbacks are not implemented yet diff --git a/litellm-rust/crates/router/src/lib.rs b/litellm-rust/crates/router/src/lib.rs index fb48213e8a7..b75c1a14446 100644 --- a/litellm-rust/crates/router/src/lib.rs +++ b/litellm-rust/crates/router/src/lib.rs @@ -2,16 +2,33 @@ mod deployment; use std::collections::HashMap; -use litellm_config::Model; +use litellm_config::{Config, Spelled}; +use litellm_core_utils::{serde_compat::parse_pydantic_bool, settings::Lookup}; +use litellm_inference_messages::{MessagesSettings, MessagesShaping}; pub use deployment::Deployment; #[derive(Clone, Debug, Default)] pub struct Router(HashMap); +fn is_on(flag: Option<&Spelled>, env: &impl Lookup) -> bool { + match flag { + Some(Spelled::Value(on)) => *on, + Some(Spelled::Text(text)) => match text.strip_prefix("os.environ/") { + Some(name) => env + .get(name) + .is_some_and(|value| parse_pydantic_bool(&value) == Some(true)), + None => parse_pydantic_bool(text) == Some(true), + }, + None => false, + } +} + impl Router { - pub fn from_model_list(model_list: &[Model]) -> Self { - model_list + pub fn from_config(config: &Config, env: &impl Lookup) -> Self { + let drops_params_globally = is_on(config.litellm_settings.drop_params.as_ref(), env); + config + .model_list .iter() .map(|model| { ( @@ -26,6 +43,14 @@ impl Router { api_base: model.litellm_params.api_base.clone(), custom_llm_provider: model.litellm_params.custom_llm_provider.clone(), litellm_params: model.litellm_params.clone(), + shaping: MessagesShaping { + settings: MessagesSettings { + drop_params: drops_params_globally + || is_on(model.litellm_params.drop_params.as_ref(), env), + ..MessagesSettings::default() + }, + ..MessagesShaping::default() + }, ..Deployment::default() }, ) @@ -43,3 +68,60 @@ impl FromIterator<(String, Deployment)> for Router { Self(entries.into_iter().collect()) } } + +#[cfg(test)] +mod tests { + use litellm_config::Config; + use rstest::rstest; + + use super::Router; + + #[rstest] + #[case::unset("", "", false)] + #[case::deployment_flag("", "\n drop_params: true", true)] + #[case::deployment_word("", "\n drop_params: \" True \"", true)] + #[case::deployment_off("", "\n drop_params: false", false)] + #[case::deployment_unknown_word("", "\n drop_params: maybe", false)] + #[case::deployment_yes("", "\n drop_params: yes", true)] + #[case::deployment_no("", "\n drop_params: \"no\"", false)] + #[case::deployment_env_reference("", "\n drop_params: os.environ/DROP_PARAMS_FLAG", true)] + #[case::deployment_unset_env_reference( + "", + "\n drop_params: os.environ/MISSING_FLAG", + false + )] + #[case::global_env_reference( + "litellm_settings:\n drop_params: os.environ/DROP_PARAMS_FLAG\n", + "", + true + )] + #[case::global_flag("litellm_settings:\n drop_params: true\n", "", true)] + #[case::global_word("litellm_settings:\n drop_params: \"true\"\n", "", true)] + #[case::global_off("litellm_settings:\n drop_params: false\n", "", false)] + #[case::global_over_deployment_off( + "litellm_settings:\n drop_params: true\n", + "\n drop_params: false", + true + )] + fn configured_drop_params_reach_the_deployment( + #[case] settings: &str, + #[case] deployment: &str, + #[case] expected: bool, + ) { + let config = Config::from_yaml(&format!( + "{settings}model_list:\n - model_name: public-model\n litellm_params:\n model: provider/model{deployment}" + )) + .unwrap(); + + assert_eq!( + Router::from_config(&config, &|name: &str| (name == "DROP_PARAMS_FLAG") + .then(|| " Yes ".to_owned())) + .get("public-model") + .unwrap() + .shaping + .settings + .drop_params, + expected + ); + } +} diff --git a/litellm-rust/crates/router/tests/router.rs b/litellm-rust/crates/router/tests/router.rs index bbaeb806b96..03e78ab9d11 100644 --- a/litellm-rust/crates/router/tests/router.rs +++ b/litellm-rust/crates/router/tests/router.rs @@ -16,7 +16,7 @@ fn configuration_preserves_deployment_parameters(#[case] parameters: &str) { "model_list:\n - model_name: public-model\n litellm_params:\n model: provider/model\n {parameters}" )) .unwrap(); - let router = Router::from_model_list(&config.model_list); + let router = Router::from_config(&config, &|_: &str| None); let deployment = router.get(&config.model_list[0].model_name).unwrap(); let params = &config.model_list[0].litellm_params; @@ -44,7 +44,7 @@ fn configuration_types_the_provider_params_of_a_deployment() { azure_ad_token: ignored-by-every-group", ) .unwrap(); - let router = Router::from_model_list(&config.model_list); + let router = Router::from_config(&config, &|_: &str| None); let params = &router.get("public-model").unwrap().litellm_params; assert_eq!( @@ -74,7 +74,7 @@ fn lookup_uses_public_names(#[case] name: &str, #[case] expected: Option<&str>) model: provider/b", ) .unwrap(); - let router = Router::from_model_list(&config.model_list); + let router = Router::from_config(&config, &|_: &str| None); assert_eq!(router.get(name).map(|entry| entry.model.as_str()), expected); } @@ -84,7 +84,7 @@ fn empty_configuration_has_no_deployment() { let config = Config::from_yaml("model_list: []").unwrap(); assert!( - Router::from_model_list(&config.model_list) + Router::from_config(&config, &|_: &str| None) .get("") .is_none() ); diff --git a/litellm/__init__.py b/litellm/__init__.py index 8dfe5adca1c..c649a48211a 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1336,7 +1336,6 @@ all_embedding_models = ( ) ####### IMAGE GENERATION MODELS ################### -openai_image_generation_models = ["dall-e-2", "dall-e-3"] ####### VIDEO GENERATION MODELS ################### openai_video_generation_models = ["sora-2"] diff --git a/litellm/constants.py b/litellm/constants.py index 1d5c6404bfd..25770d960bb 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -764,7 +764,6 @@ ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES: Final = { # Used for web search interception across providers LITELLM_WEB_SEARCH_TOOL_NAME: Final = "litellm_web_search" -DEFAULT_IMAGE_ENDPOINT_MODEL: Final = "dall-e-2" DEFAULT_VIDEO_ENDPOINT_MODEL: Final = "sora-2" DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS: Final = get_env_int("DEFAULT_GOOGLE_VIDEO_DURATION_SECONDS", 8) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 86b22069a39..087d5b5b718 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1592,14 +1592,6 @@ def completion_cost( for part in ws_tier_parts ) - if ( - (call_type == "aimage_generation" or call_type == "image_generation") - and model is not None - and isinstance(model, str) - and len(model) == 0 - and custom_llm_provider == "azure" - ): - model = "dall-e-2" # for dall-e-2, azure expects an empty model name # Handle Inputs to completion_cost prompt_tokens = 0 prompt_characters: int | None = None @@ -1674,7 +1666,8 @@ def completion_cost( raw_candidates if custom_pricing is True else _priced_candidates_before_unpriced_rules(raw_candidates) ) - for idx, (model, candidate_pricing_provider) in enumerate(potential_model_names): + for idx, (candidate_model, candidate_pricing_provider) in enumerate(potential_model_names): + model = candidate_model try: if verbose_logger.isEnabledFor(logging.DEBUG): verbose_logger.debug("selected model name for cost calculation: %s", model) diff --git a/litellm/images/main.py b/litellm/images/main.py index d0618e5e804..6e77cbb7046 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -16,7 +16,6 @@ import litellm # client is imported from litellm as it's a decorator from litellm import client -from litellm.constants import DEFAULT_IMAGE_ENDPOINT_MODEL from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT from litellm.exceptions import LiteLLMUnknownProvider from litellm.litellm_core_utils.get_llm_provider_logic import ( @@ -299,17 +298,14 @@ def image_generation( if custom_llm_provider == "chatgpt" or (model is not None and model.startswith("chatgpt/")) else None ) - dynamic_api_key: str | None = None - if model is not None or custom_llm_provider is not None: - model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider( - model=model, - custom_llm_provider=custom_llm_provider, - api_base=api_base, - litellm_params=chatgpt_params, - ) - else: - model = "dall-e-2" - custom_llm_provider = "openai" # default to dall-e-2 on openai + if model is None and custom_llm_provider is None: + raise litellm.BadRequestError(message="model is required", model="", llm_provider="") + model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider( + model=model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + litellm_params=chatgpt_params, + ) model_response.hidden_params["model"] = model openai_params: Final = [ "user", @@ -712,7 +708,7 @@ async def aimage_variation(*args, **kwargs) -> ImageResponse: @client def image_variation( image: FileTypes, - model: str = "dall-e-2", # set to dall-e-2 by default - like OpenAI. + model: str | None = None, n: int = 1, response_format: Literal["url", "b64_json"] = "url", size: str | None = None, @@ -728,7 +724,7 @@ def image_variation( litellm_params: Final = get_litellm_params(**kwargs) # get the custom llm provider model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider( - model=model, + model=model or "", custom_llm_provider=litellm_params.get("custom_llm_provider", None), api_base=litellm_params.get("api_base", None), api_key=litellm_params.get("api_key", None), @@ -747,7 +743,7 @@ def image_variation( response: ImageResponse | None = None provider_config: Final = ProviderConfigManager.get_provider_model_info( - model=model or "", # openai defaults to dall-e-2 + model=model, provider=llm_provider, ) @@ -772,7 +768,7 @@ def image_variation( model_response=model_response, api_key=api_key, api_base=api_base, - model=model, + model=model or None, image=image, timeout=litellm_params.get("timeout", None), custom_llm_provider=custom_llm_provider, @@ -790,7 +786,7 @@ def image_variation( model_response=model_response, api_key=api_key, api_base=api_base, - model=model, + model=model or None, image=image, timeout=litellm_params.get("timeout", None) or DEFAULT_REQUEST_TIMEOUT, custom_llm_provider=custom_llm_provider, @@ -901,7 +897,7 @@ def image_edit( # get llm provider logic litellm_params: Final = GenericLiteLLMParams(**kwargs) model, custom_llm_provider, _, _ = get_llm_provider( - model=model or DEFAULT_IMAGE_ENDPOINT_MODEL, + model=model or "", custom_llm_provider=custom_llm_provider, ) diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 19c1d5c414c..d9933087334 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -437,7 +437,6 @@ def get_llm_provider( model in litellm.open_ai_chat_completion_models or "ft:gpt-3.5-turbo" in model or "ft:gpt-4" in model # catches ft:gpt-4-0613, ft:gpt-4o - or model in litellm.openai_image_generation_models or model.startswith("gpt-image") or model in litellm.openai_video_generation_models ): diff --git a/litellm/llms/azure/azure.py b/litellm/llms/azure/azure.py index 02f1ba3472a..5a181faad11 100644 --- a/litellm/llms/azure/azure.py +++ b/litellm/llms/azure/azure.py @@ -13,13 +13,12 @@ from openai import ( BadRequestError, OpenAI, ) -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel import litellm -from litellm.constants import AZURE_OPERATION_POLLING_TIMEOUT, DEFAULT_MAX_RETRIES +from litellm.constants import DEFAULT_MAX_RETRIES from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.logging_utils import speech_request_body, track_llm_api_timing -from litellm.litellm_core_utils.url_utils import SSRFError, assert_same_origin from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -34,7 +33,6 @@ from litellm.types.utils import ( from litellm.utils import ( CustomStreamWrapper, convert_to_model_response_object, - modify_url, ) from ...types.llms.openai import HttpxBinaryResponseContent @@ -59,8 +57,6 @@ from .image_generation import ( ) from .image_generation.http_utils import azure_deployment_image_generation_json_body -_DECODED_JSON: Final = TypeAdapter(object) - class AzureOpenAIAssistantsAPIConfig: """ @@ -860,11 +856,6 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): headers: dict, deployment_name: str | None = None, ) -> httpx.Response: - """ - Implemented for azure dall-e-2 image gen calls - - Alternative to needing a custom transport implementation - """ if client is None: _params: Final = {} if timeout is not None: @@ -881,95 +872,6 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): else: async_handler = client - if ( - "images/generations" in api_base - and api_version - in [ # dall-e-3 starts from `2023-12-01-preview` so we should be able to avoid conflict - "2023-06-01-preview", - "2023-07-01-preview", - "2023-08-01-preview", - "2023-09-01-preview", - "2023-10-01-preview", - ] - ): # CREATE + POLL for azure dall-e-2 calls - api_base = modify_url(original_url=api_base, new_path="/openai/images/generations:submit") - - data.pop( - "model", None - ) # REMOVE 'model' from dall-e-2 arg https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#request-a-generated-image-dall-e-2-preview - response = await async_handler.post( - url=api_base, - data=json.dumps(data), - headers=headers, - ) - if "operation-location" in response.headers: - operation_location_url: Final = response.headers["operation-location"] - else: - raise AzureOpenAIError(status_code=500, message=response.text) - # Reject polling URLs that don't share an origin with ``api_base``. - # Without this an upstream-controlled or attacker-controlled - # value would receive the operator's Azure API key in the - # request headers below. VERIA-51. - try: - assert_same_origin(operation_location_url, api_base) - except SSRFError as ssrf_err: - raise AzureOpenAIError( - status_code=502, - message=f"Rejected polling URL: {ssrf_err}", - ) - response = await async_handler.get( - url=operation_location_url, - headers=headers, - ) - - await response.aread() - - timeout_secs: Final[int] = AZURE_OPERATION_POLLING_TIMEOUT - start_time: Final = time.time() - if "status" not in response.json(): - # Don't reflect the raw response body — when the polling - # URL points at an internal JSON API (cloud metadata - # service etc.) reflecting it here turns Blind SSRF into - # Full-Read SSRF. VERIA-51. - raise AzureOpenAIError( - status_code=502, - message="Polling response missing 'status' field", - ) - while response.json()["status"] not in ["succeeded", "failed"]: - if time.time() - start_time > timeout_secs: - raise AzureOpenAIError(status_code=408, message="Operation polling timed out.") - - await asyncio.sleep(int(response.headers.get("retry-after") or 10)) - response = await async_handler.get( - url=operation_location_url, - headers=headers, - ) - await response.aread() - - if response.json()["status"] == "failed": - error_data: Final = response.json() - # Preserve Azure error details (e.g. content_policy_violation, - # inner_error, content_filter_results) as structured body so - # exception_type() can route them correctly. - _error_body: Final = error_data.get("error", error_data) - _error_msg: Final = ( - _error_body.get("message", "Image generation failed") - if isinstance(_error_body, dict) - else json.dumps(error_data) - ) - raise AzureOpenAIError( - status_code=400, - message=_error_msg, - body=error_data, - ) - - result: Final = _DECODED_JSON.validate_python(response.json()["result"]) - return httpx.Response( - status_code=200, - headers=response.headers, - content=json.dumps(result).encode("utf-8"), - request=httpx.Request(method="POST", url="https://api.openai.com/v1"), - ) request_json: Final = azure_deployment_image_generation_json_body(api_base, data, deployment_name) return await async_handler.post( url=api_base, @@ -988,11 +890,6 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): headers: dict, deployment_name: str | None = None, ) -> httpx.Response: - """ - Implemented for azure dall-e-2 image gen calls - - Alternative to needing a custom transport implementation - """ if client is None: _params: Final = {} if timeout is not None: @@ -1006,87 +903,6 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): else: sync_handler = client - if ( - "images/generations" in api_base - and api_version - in [ # dall-e-3 starts from `2023-12-01-preview` so we should be able to avoid conflict - "2023-06-01-preview", - "2023-07-01-preview", - "2023-08-01-preview", - "2023-09-01-preview", - "2023-10-01-preview", - ] - ): # CREATE + POLL for azure dall-e-2 calls - api_base = modify_url(original_url=api_base, new_path="/openai/images/generations:submit") - - data.pop( - "model", None - ) # REMOVE 'model' from dall-e-2 arg https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#request-a-generated-image-dall-e-2-preview - response = sync_handler.post( - url=api_base, - data=json.dumps(data), - headers=headers, - ) - if "operation-location" in response.headers: - operation_location_url: Final = response.headers["operation-location"] - else: - raise AzureOpenAIError(status_code=500, message=response.text) - try: - assert_same_origin(operation_location_url, api_base) - except SSRFError as ssrf_err: - raise AzureOpenAIError( - status_code=502, - message=f"Rejected polling URL: {ssrf_err}", - ) - response = sync_handler.get( - url=operation_location_url, - headers=headers, - ) - - response.read() - - timeout_secs: Final[int] = AZURE_OPERATION_POLLING_TIMEOUT - start_time: Final = time.time() - if "status" not in response.json(): - raise AzureOpenAIError( - status_code=502, - message="Polling response missing 'status' field", - ) - while response.json()["status"] not in ["succeeded", "failed"]: - if time.time() - start_time > timeout_secs: - raise AzureOpenAIError(status_code=408, message="Operation polling timed out.") - - time.sleep(int(response.headers.get("retry-after") or 10)) - response = sync_handler.get( - url=operation_location_url, - headers=headers, - ) - response.read() - - if response.json()["status"] == "failed": - error_data: Final = response.json() - # Preserve Azure error details (e.g. content_policy_violation, - # inner_error, content_filter_results) as structured body so - # exception_type() can route them correctly. - _error_body: Final = error_data.get("error", error_data) - _error_msg: Final = ( - _error_body.get("message", "Image generation failed") - if isinstance(_error_body, dict) - else json.dumps(error_data) - ) - raise AzureOpenAIError( - status_code=400, - message=_error_msg, - body=error_data, - ) - - result: Final = _DECODED_JSON.validate_python(response.json()["result"]) - return httpx.Response( - status_code=200, - headers=response.headers, - content=json.dumps(result).encode("utf-8"), - request=httpx.Request(method="POST", url="https://api.openai.com/v1"), - ) request_json: Final = azure_deployment_image_generation_json_body(api_base, data, deployment_name) return sync_handler.post( url=api_base, @@ -1193,10 +1009,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): deployment_name=model, ) - provider_config: Final = get_azure_image_generation_config(data.get("model", "dall-e-2")) + provider_config: Final = get_azure_image_generation_config(data.get("model", "")) if provider_config is not None: return provider_config.transform_image_generation_response( - model=data.get("model", "dall-e-2"), + model=data.get("model", ""), raw_response=httpx_response, model_response=model_response or ImageResponse(), logging_obj=logging_obj, @@ -1329,10 +1145,10 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM): headers=request_headers, deployment_name=model, ) - provider_config: Final = get_azure_image_generation_config(data.get("model", "dall-e-2")) + provider_config: Final = get_azure_image_generation_config(data.get("model", "")) if isinstance(provider_config, AzureFoundryMAIImageGenerationConfig): return provider_config.transform_image_generation_response( - model=data.get("model", "dall-e-2"), + model=data.get("model", ""), raw_response=httpx_response, model_response=model_response or ImageResponse(), logging_obj=logging_obj, diff --git a/litellm/llms/azure/image_generation/__init__.py b/litellm/llms/azure/image_generation/__init__.py index a2a905f2287..7e9b5f5d99c 100644 --- a/litellm/llms/azure/image_generation/__init__.py +++ b/litellm/llms/azure/image_generation/__init__.py @@ -4,14 +4,10 @@ from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) -from .dall_e_2_transformation import AzureDallE2ImageGenerationConfig -from .dall_e_3_transformation import AzureDallE3ImageGenerationConfig from .gpt_transformation import AzureGPTImageGenerationConfig from .http_utils import azure_deployment_image_generation_json_body __all__ = [ - "AzureDallE2ImageGenerationConfig", - "AzureDallE3ImageGenerationConfig", "AzureGPTImageGenerationConfig", "azure_deployment_image_generation_json_body", ] @@ -21,11 +17,7 @@ def get_azure_image_generation_config(model: str) -> BaseImageGenerationConfig: model = model.lower() model = model.replace("-", "") model = model.replace("_", "") - if model == "" or "dalle2" in model: # empty model is dall-e-2 - return AzureDallE2ImageGenerationConfig() - elif "dalle3" in model: - return AzureDallE3ImageGenerationConfig() - elif AzureFoundryMAIImageGenerationConfig.is_mai_model(model): + if AzureFoundryMAIImageGenerationConfig.is_mai_model(model): return AzureFoundryMAIImageGenerationConfig() else: verbose_logger.debug( diff --git a/litellm/llms/azure/image_generation/dall_e_2_transformation.py b/litellm/llms/azure/image_generation/dall_e_2_transformation.py deleted file mode 100644 index 788753e4ea9..00000000000 --- a/litellm/llms/azure/image_generation/dall_e_2_transformation.py +++ /dev/null @@ -1,7 +0,0 @@ -from litellm.llms.openai.image_generation import DallE2ImageGenerationConfig - - -class AzureDallE2ImageGenerationConfig(DallE2ImageGenerationConfig): - """ - Azure dall-e-2 image generation config - """ diff --git a/litellm/llms/azure/image_generation/dall_e_3_transformation.py b/litellm/llms/azure/image_generation/dall_e_3_transformation.py deleted file mode 100644 index e6974f9317e..00000000000 --- a/litellm/llms/azure/image_generation/dall_e_3_transformation.py +++ /dev/null @@ -1,7 +0,0 @@ -from litellm.llms.openai.image_generation import DallE3ImageGenerationConfig - - -class AzureDallE3ImageGenerationConfig(DallE3ImageGenerationConfig): - """ - Azure dall-e-3 image generation config - """ diff --git a/litellm/llms/azure_ai/image_generation/__init__.py b/litellm/llms/azure_ai/image_generation/__init__.py index 742922ad295..bd3c11d36f9 100644 --- a/litellm/llms/azure_ai/image_generation/__init__.py +++ b/litellm/llms/azure_ai/image_generation/__init__.py @@ -3,15 +3,11 @@ from litellm.llms.base_llm.image_generation.transformation import ( BaseImageGenerationConfig, ) -from .dall_e_2_transformation import AzureFoundryDallE2ImageGenerationConfig -from .dall_e_3_transformation import AzureFoundryDallE3ImageGenerationConfig from .flux_transformation import AzureFoundryFluxImageGenerationConfig from .gpt_transformation import AzureFoundryGPTImageGenerationConfig from .mai_transformation import AzureFoundryMAIImageGenerationConfig __all__ = [ - "AzureFoundryDallE2ImageGenerationConfig", - "AzureFoundryDallE3ImageGenerationConfig", "AzureFoundryFluxImageGenerationConfig", "AzureFoundryGPTImageGenerationConfig", "AzureFoundryMAIImageGenerationConfig", @@ -22,11 +18,7 @@ def get_azure_ai_image_generation_config(model: str) -> BaseImageGenerationConfi model = model.lower() model = model.replace("-", "") model = model.replace("_", "") - if model == "" or "dalle2" in model: # empty model is dall-e-2 - return AzureFoundryDallE2ImageGenerationConfig() - elif "dalle3" in model: - return AzureFoundryDallE3ImageGenerationConfig() - elif AzureFoundryMAIImageGenerationConfig.is_mai_model(model): + if AzureFoundryMAIImageGenerationConfig.is_mai_model(model): return AzureFoundryMAIImageGenerationConfig() elif "flux" in model: return AzureFoundryFluxImageGenerationConfig() diff --git a/litellm/llms/azure_ai/image_generation/dall_e_2_transformation.py b/litellm/llms/azure_ai/image_generation/dall_e_2_transformation.py deleted file mode 100644 index 2c8a5426628..00000000000 --- a/litellm/llms/azure_ai/image_generation/dall_e_2_transformation.py +++ /dev/null @@ -1,7 +0,0 @@ -from litellm.llms.openai.image_generation import DallE2ImageGenerationConfig - - -class AzureFoundryDallE2ImageGenerationConfig(DallE2ImageGenerationConfig): - """ - Azure dall-e-2 image generation config - """ diff --git a/litellm/llms/azure_ai/image_generation/dall_e_3_transformation.py b/litellm/llms/azure_ai/image_generation/dall_e_3_transformation.py deleted file mode 100644 index 3ceb9b405da..00000000000 --- a/litellm/llms/azure_ai/image_generation/dall_e_3_transformation.py +++ /dev/null @@ -1,7 +0,0 @@ -from litellm.llms.openai.image_generation import DallE3ImageGenerationConfig - - -class AzureFoundryDallE3ImageGenerationConfig(DallE3ImageGenerationConfig): - """ - Azure dall-e-3 image generation config - """ diff --git a/litellm/llms/custom_httpx/aiohttp_handler.py b/litellm/llms/custom_httpx/aiohttp_handler.py index 4060d84f7f4..e6544968d24 100644 --- a/litellm/llms/custom_httpx/aiohttp_handler.py +++ b/litellm/llms/custom_httpx/aiohttp_handler.py @@ -561,11 +561,8 @@ class BaseLLMAIOHTTPHandler: organization: str | None = None, headers: dict | None = None, ) -> ImageResponse: - if model is None: - raise ValueError("model is required for non-openai image variations") - provider_config: Final = ProviderConfigManager.get_provider_image_variation_config( - model=model, # openai defaults to dall-e-2 + model=model or "", provider=LlmProviders(custom_llm_provider), ) diff --git a/litellm/llms/openai/image_edit/__init__.py b/litellm/llms/openai/image_edit/__init__.py index 63a41b65e8a..261010071c9 100644 --- a/litellm/llms/openai/image_edit/__init__.py +++ b/litellm/llms/openai/image_edit/__init__.py @@ -1,31 +1,5 @@ -from typing import Final - -from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig - -from .dalle2_transformation import DallE2ImageEditConfig from .transformation import OpenAIImageEditConfig __all__ = [ - "DallE2ImageEditConfig", "OpenAIImageEditConfig", - "get_openai_image_edit_config", ] - - -def get_openai_image_edit_config(model: str) -> BaseImageEditConfig: - """ - Get the appropriate OpenAI image edit config based on the model. - - Args: - model: The model name (e.g., "dall-e-2", "gpt-image-1") - - Returns: - The appropriate config instance for the model - """ - model_normalized: Final = model.lower().replace("-", "").replace("_", "") - - if model_normalized == "dalle2": - return DallE2ImageEditConfig() - else: - # Default to standard OpenAI config for gpt-image-1 and other models - return OpenAIImageEditConfig() diff --git a/litellm/llms/openai/image_edit/dalle2_transformation.py b/litellm/llms/openai/image_edit/dalle2_transformation.py deleted file mode 100644 index f349959b14f..00000000000 --- a/litellm/llms/openai/image_edit/dalle2_transformation.py +++ /dev/null @@ -1,98 +0,0 @@ -from io import BufferedReader -from typing import TYPE_CHECKING, Any, Final, cast - -from httpx._types import RequestFiles - -import litellm -from litellm.images.utils import ImageEditRequestUtils -from litellm.types.images.main import ImageEditRequestParams -from litellm.types.llms.openai import FileTypes -from litellm.types.router import GenericLiteLLMParams - -from .transformation import OpenAIImageEditConfig - -if TYPE_CHECKING: - from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj - - LiteLLMLoggingObj = _LiteLLMLoggingObj -else: - LiteLLMLoggingObj = Any - - -class DallE2ImageEditConfig(OpenAIImageEditConfig): - """ - DALL-E-2 specific configuration for image edit API. - - DALL-E-2 only supports editing a single image (not an array). - Uses "image" field name instead of "image[]". - """ - - def transform_image_edit_request( - self, - model: str, - prompt: str | None, - image: FileTypes | None, - image_edit_optional_request_params: dict, - litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[dict, RequestFiles]: - """ - Transform image edit request for DALL-E-2. - - DALL-E-2 only accepts a single image with field name "image" (not "image[]"). - """ - request_params: Final = { - "model": model, - **image_edit_optional_request_params, - } - if image is not None: - request_params["image"] = image - if prompt is not None: - request_params["prompt"] = prompt - - request: Final = ImageEditRequestParams(**request_params) - request_dict: Final = cast(dict, request) - - ######################################################### - # Separate images and masks as `files` and send other parameters as `data` - ######################################################### - _image_list: Final = request_dict.get("image") - _mask = request_dict.get("mask") - data_without_files: Final = {k: v for k, v in request_dict.items() if k not in ["image", "mask"]} - files_list: Final[list[tuple[str, Any]]] = [] - - # Handle image parameter - DALL-E-2 only supports single image - if _image_list is not None: - image_list: Final = [_image_list] if not isinstance(_image_list, list) else _image_list - - # Validate only one image is provided - if len(image_list) > 1: - raise litellm.BadRequestError( - message="DALL-E-2 only supports editing a single image. Please provide one image.", - model=model, - llm_provider="openai", - ) - - # Use "image" field name (singular) for DALL-E-2 - for _image in image_list: - if _image is not None: - self._add_image_to_files( - files_list=files_list, - image=_image, - field_name="image", - ) - - # Handle mask parameter if provided - if _mask is not None: - # Handle case where mask can be a list (extract first mask) - if isinstance(_mask, list): - _mask = _mask[0] if _mask else None - - if _mask is not None: - mask_content_type: Final[str] = ImageEditRequestUtils.get_image_content_type(_mask) - if isinstance(_mask, BufferedReader): - files_list.append(("mask", (_mask.name, _mask, mask_content_type))) - else: - files_list.append(("mask", ("mask.png", _mask, mask_content_type))) - - return data_without_files, files_list diff --git a/litellm/llms/openai/image_edit/transformation.py b/litellm/llms/openai/image_edit/transformation.py index 9def833052b..0bba58096ef 100644 --- a/litellm/llms/openai/image_edit/transformation.py +++ b/litellm/llms/openai/image_edit/transformation.py @@ -96,7 +96,7 @@ class OpenAIImageEditConfig(BaseImageEditConfig): """ # Build request params, only including non-None values request_params: Final = { - "model": model, + **({"model": model} if model else {}), **image_edit_optional_request_params, } if image is not None: diff --git a/litellm/llms/openai/image_generation/__init__.py b/litellm/llms/openai/image_generation/__init__.py index e20c80f20bb..4cffbe81a0e 100644 --- a/litellm/llms/openai/image_generation/__init__.py +++ b/litellm/llms/openai/image_generation/__init__.py @@ -1,9 +1,3 @@ -from litellm.llms.base_llm.image_generation.transformation import ( - BaseImageGenerationConfig, -) - -from .dall_e_2_transformation import DallE2ImageGenerationConfig -from .dall_e_3_transformation import DallE3ImageGenerationConfig from .gpt_transformation import GPTImageGenerationConfig from .guardrail_translation import ( OpenAIImageGenerationHandler, @@ -11,18 +5,7 @@ from .guardrail_translation import ( ) __all__ = [ - "DallE2ImageGenerationConfig", - "DallE3ImageGenerationConfig", "GPTImageGenerationConfig", "OpenAIImageGenerationHandler", "guardrail_translation_mappings", ] - - -def get_openai_image_generation_config(model: str) -> BaseImageGenerationConfig: - if model.startswith("dall-e-2") or model == "": # empty model is dall-e-2 - return DallE2ImageGenerationConfig() - elif model.startswith("dall-e-3"): - return DallE3ImageGenerationConfig() - else: - return GPTImageGenerationConfig() diff --git a/litellm/llms/openai/image_generation/dall_e_2_transformation.py b/litellm/llms/openai/image_generation/dall_e_2_transformation.py deleted file mode 100644 index ffc6f1d5fe9..00000000000 --- a/litellm/llms/openai/image_generation/dall_e_2_transformation.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import TYPE_CHECKING, Final - -import httpx - -from litellm.llms.base_llm.image_generation.transformation import ( - BaseImageGenerationConfig, -) -from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams -from litellm.types.utils import ImageResponse -from litellm.utils import convert_to_model_response_object - -if TYPE_CHECKING: - from litellm.litellm_core_utils.logging import Logging as LiteLLMLoggingObj - - from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer - - -class DallE2ImageGenerationConfig(BaseImageGenerationConfig): - """ - OpenAI dall-e-2 image generation config - """ - - def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]: - return ["n", "response_format", "quality", "size", "user"] - - def map_openai_params( - self, - non_default_params: dict, - optional_params: dict, - model: str, - drop_params: bool, - ) -> dict: - supported_params: Final = self.get_supported_openai_params(model) - for k in non_default_params: - if k not in optional_params: - if k in supported_params: - optional_params[k] = non_default_params[k] - elif drop_params: - pass - else: - raise ValueError( - f"Parameter {k} is not supported for model {model}. Supported parameters are {supported_params}. Set drop_params=True to drop unsupported parameters." - ) - - return optional_params - - def transform_image_generation_response( - self, - model: str, - raw_response: httpx.Response, - model_response: ImageResponse, - logging_obj: "LiteLLMLoggingObj", - request_data: dict, - optional_params: dict, - litellm_params: dict, - encoding: "Tokenizer | None", - api_key: str | None = None, - json_mode: bool | None = None, - ) -> ImageResponse: - response: Final = raw_response.json() - - stringified_response: Final = response - ## LOGGING - logging_obj.post_call( - input=request_data.get("prompt", ""), - api_key=api_key, - additional_args={"complete_input_dict": request_data}, - original_response=stringified_response, - ) - image_response: Final[ImageResponse] = convert_to_model_response_object( - response_object=stringified_response, - model_response_object=model_response, - response_type="image_generation", - ) - - # set optional params - image_response.size = optional_params.get("size", "1024x1024") # default is always 1024x1024 - image_response.quality = optional_params.get("quality", "standard") # always standard for dall-e-2 - image_response.output_format = optional_params.get("output_format", "png") # always png for dall-e-2 - - return image_response diff --git a/litellm/llms/openai/image_generation/dall_e_3_transformation.py b/litellm/llms/openai/image_generation/dall_e_3_transformation.py deleted file mode 100644 index 90b7eaedf2f..00000000000 --- a/litellm/llms/openai/image_generation/dall_e_3_transformation.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import TYPE_CHECKING, Final - -import httpx - -from litellm.llms.base_llm.image_generation.transformation import ( - BaseImageGenerationConfig, -) -from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams -from litellm.types.utils import ImageResponse -from litellm.utils import convert_to_model_response_object - -if TYPE_CHECKING: - from litellm.litellm_core_utils.logging import Logging as LiteLLMLoggingObj - - from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer - - -class DallE3ImageGenerationConfig(BaseImageGenerationConfig): - """ - OpenAI dall-e-3 image generation config - """ - - def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]: - return ["n", "response_format", "quality", "size", "user", "style"] - - def map_openai_params( - self, - non_default_params: dict, - optional_params: dict, - model: str, - drop_params: bool, - ) -> dict: - supported_params: Final = self.get_supported_openai_params(model) - for k in non_default_params: - if k not in optional_params: - if k in supported_params: - optional_params[k] = non_default_params[k] - elif drop_params: - pass - else: - raise ValueError( - f"Parameter {k} is not supported for model {model}. Supported parameters are {supported_params}. Set drop_params=True to drop unsupported parameters." - ) - - return optional_params - - def transform_image_generation_response( - self, - model: str, - raw_response: httpx.Response, - model_response: ImageResponse, - logging_obj: "LiteLLMLoggingObj", - request_data: dict, - optional_params: dict, - litellm_params: dict, - encoding: "Tokenizer | None", - api_key: str | None = None, - json_mode: bool | None = None, - ) -> ImageResponse: - response: Final = raw_response.json() - - stringified_response: Final = response - ## LOGGING - logging_obj.post_call( - input=request_data.get("prompt", ""), - api_key=api_key, - additional_args={"complete_input_dict": request_data}, - original_response=stringified_response, - ) - image_response: Final[ImageResponse] = convert_to_model_response_object( - response_object=stringified_response, - model_response_object=model_response, - response_type="image_generation", - ) - - # set optional params - image_response.size = optional_params.get("size", "1024x1024") # default is always 1024x1024 - image_response.quality = optional_params.get("quality", "hd") # always hd for dall-e-3 - image_response.output_format = optional_params.get("output_format", "png") # always png for dall-e-3 - - return image_response diff --git a/litellm/llms/openai/image_variations/handler.py b/litellm/llms/openai/image_variations/handler.py index 89dcdc40e02..01f01d3de1d 100644 --- a/litellm/llms/openai/image_variations/handler.py +++ b/litellm/llms/openai/image_variations/handler.py @@ -151,7 +151,7 @@ class OpenAIImageVariationsHandler: ) -> ImageResponse: try: provider_config: Final = ProviderConfigManager.get_provider_image_variation_config( - model=model or "", # openai defaults to dall-e-2 + model=model or "", provider=LlmProviders.OPENAI, ) diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py index 6d8eb228786..1f0ee728cd1 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/messages/transformation.py @@ -1,6 +1,8 @@ from collections.abc import Mapping, Sequence from typing import Final +from litellm.exceptions import UnsupportedParamsError +from litellm.litellm_core_utils.core_helpers import drops_unsupported_params from litellm.llms.anthropic.common_utils import AnthropicModelInfo from litellm.llms.anthropic.messages.native.transformation import ( AnthropicMessagesConfig, @@ -17,6 +19,41 @@ from litellm.types.router import GenericLiteLLMParams from ....vertex_llm_base import VertexBase from ..output_params_utils import sanitize_vertex_anthropic_output_params +_VERSION_HEADER_VERTEX_READS_FROM_BODY: Final = "anthropic-version" +_VERTEX_DEFAULT_VERSION: Final = "vertex-2023-10-16" +_VERSIONS_SERVED_AS_VERTEX_DEFAULT: Final = frozenset({"", "2023-01-01", "2023-06-01"}) +_VERSIONS_VERTEX_ACCEPTS_IN_BODY: Final = frozenset({_VERTEX_DEFAULT_VERSION, "2026-10-21"}) + + +def _requested_version(headers: Mapping[str, object]) -> str: + requested: Final = next( + (value for name, value in headers.items() if name.lower() == _VERSION_HEADER_VERTEX_READS_FROM_BODY), + "", + ) + return requested.strip() if isinstance(requested, str) else "" + + +def _vertex_version(headers: Mapping[str, object], model: str, drop_params: bool) -> str | UnsupportedParamsError: + requested: Final = _requested_version(headers) + if requested in _VERSIONS_SERVED_AS_VERTEX_DEFAULT: + return _VERTEX_DEFAULT_VERSION + if requested in _VERSIONS_VERTEX_ACCEPTS_IN_BODY: + return requested + if drop_params: + return _VERTEX_DEFAULT_VERSION + return UnsupportedParamsError( + message=( + f"{model} does not support {_VERSION_HEADER_VERTEX_READS_FROM_BODY}={requested}. " + "To drop unsupported params, set `litellm.drop_params = True`." + ), + llm_provider="vertex_ai", + model=model, + ) + + +def _without_version_header(headers: Mapping[str, str]) -> dict[str, str]: # mutable-ok: the caller sends these headers + return {name: value for name, value in headers.items() if name.lower() != _VERSION_HEADER_VERTEX_READS_FROM_BODY} + class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, VertexBase): @property @@ -198,7 +235,7 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert messages: list[dict], anthropic_messages_optional_request_params: dict, litellm_params: GenericLiteLLMParams, - headers: dict, + headers: dict[str, object], # mutable-ok: mirrors the provider transform contract ) -> dict: anthropic_messages_request: Final = super().transform_anthropic_messages_request( model=model, @@ -212,10 +249,28 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert self._remove_scope_from_cache_control(anthropic_messages_request) - anthropic_messages_request["anthropic_version"] = "vertex-2023-10-16" + version: Final = _vertex_version( + headers, model=model, drop_params=drops_unsupported_params(litellm_params.drop_params) + ) + if isinstance(version, UnsupportedParamsError): + raise version + anthropic_messages_request["anthropic_version"] = version anthropic_messages_request.pop("model", None) # do not pass model in request body to vertex ai sanitize_vertex_anthropic_output_params(anthropic_messages_request, model) return anthropic_messages_request + + def sign_request( + self, + headers: Mapping[str, str], + optional_params: Mapping[str, object], + request_data: Mapping[str, object], + api_base: str, + api_key: str | None = None, + model: str | None = None, + stream: bool | None = None, + fake_stream: bool | None = None, + ) -> tuple[dict[str, str], bytes | None]: # mutable-ok: the caller sends these headers + return _without_version_header(headers), None diff --git a/litellm/utils.py b/litellm/utils.py index 1ef39059e1b..be1e11dd39f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7664,7 +7664,6 @@ def validate_environment( model in litellm.open_ai_chat_completion_models or model in litellm.open_ai_text_completion_models or model in litellm.open_ai_embedding_models - or model in litellm.openai_image_generation_models or model.startswith("gpt-image") ): if "OPENAI_API_KEY" in os.environ: @@ -10311,11 +10310,9 @@ class ProviderConfigManager: provider: LlmProviders, ) -> BaseImageGenerationConfig | None: if LlmProviders.OPENAI == provider: - from litellm.llms.openai.image_generation import ( - get_openai_image_generation_config, - ) + from litellm.llms.openai.image_generation import GPTImageGenerationConfig - return get_openai_image_generation_config(model) + return GPTImageGenerationConfig() elif LlmProviders.AZURE == provider: from litellm.llms.azure.image_generation import ( get_azure_image_generation_config, @@ -10546,9 +10543,9 @@ class ProviderConfigManager: provider: LlmProviders, ) -> BaseImageEditConfig | None: if LlmProviders.OPENAI == provider: - from litellm.llms.openai.image_edit import get_openai_image_edit_config + from litellm.llms.openai.image_edit import OpenAIImageEditConfig - return get_openai_image_edit_config(model=model) + return OpenAIImageEditConfig() elif LlmProviders.HOSTED_VLLM == provider: from litellm.llms.hosted_vllm.image_edit import get_hosted_vllm_image_edit_config diff --git a/tests/integration/mcp/test_mcp_llm_endpoints.py b/tests/integration/mcp/test_mcp_llm_endpoints.py index 394221708bf..f751d1f8f4c 100644 --- a/tests/integration/mcp/test_mcp_llm_endpoints.py +++ b/tests/integration/mcp/test_mcp_llm_endpoints.py @@ -6,7 +6,7 @@ from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from dataclasses import dataclass from hashlib import sha256 -from itertools import chain +from itertools import chain, count from pathlib import Path from typing import Final, Literal, TypeVar @@ -50,6 +50,7 @@ def _llm_gateway(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gat directory: Final = tmp_path_factory.mktemp("mcp-llm-gateway") stock: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) config: Final = directory / "stored-responses.yaml" + (directory / f"{STREAM_BLOCK_MODULE}.py").write_text(STREAM_BLOCK_SOURCE) config.write_text( yaml.safe_dump( { @@ -57,8 +58,17 @@ def _llm_gateway(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gat "general_settings": {**object_value(stock["general_settings"]), "store_prompts_in_spend_logs": True}, "litellm_settings": { **object_value(stock["litellm_settings"]), - "cost_margin_config": {"hosted_vllm": {"fixed_amount": FIXED_MARGIN}}, + "cost_margin_config": { + "hosted_vllm": {"fixed_amount": FIXED_MARGIN}, + "deepseek": {"fixed_amount": FIXED_MARGIN}, + }, }, + "guardrails": [ + { + "guardrail_name": STREAM_BLOCK, + "litellm_params": {"guardrail": f"{STREAM_BLOCK_MODULE}.BlockGuard", "mode": "post_call"}, + } + ], } ) ) @@ -79,7 +89,7 @@ def gateway(_llm_gateway: tuple[Gateway, Mapping[str, str]]) -> Iterator[Gateway yield private -Surface = Literal["chat", "responses", "messages", "messages_bridge"] +Surface = Literal["chat", "responses", "messages", "messages_bridge", "responses_bridge"] SURFACES: Final[tuple[Surface, ...]] = ("chat", "responses", "messages", "messages_bridge") ADD: Final = {"a": 2, "b": 3} ANSWER: Final = "the sum is 5" @@ -88,6 +98,19 @@ AUTO: Final = {**GATEWAY_REF, "require_approval": "never"} OUTAGE: Final = "bridge-outage" DIRECT: Final = "answer-without-tools" FIXED_MARGIN: Final = 0.25 +STREAM_BLOCK: Final = "mcp-stream-block" +STREAM_BLOCK_MODULE: Final = "mcp_stream_block" +STREAM_BLOCK_DETAIL: Final = "blocked by output policy" +STREAM_BLOCK_SOURCE: Final = ( + "from fastapi import HTTPException\n\n" + "from litellm.integrations.custom_guardrail import CustomGuardrail\n\n\n" + "class BlockGuard(CustomGuardrail):\n" + " async def async_post_call_streaming_iterator_hook(self, user_api_key_dict, response, request_data):\n" + " async for _ in response:\n" + " pass\n" + f' raise HTTPException(status_code=400, detail="{STREAM_BLOCK_DETAIL}")\n' + " yield\n" +) JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) JSON_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) @@ -128,6 +151,8 @@ class Billing: first: tuple[int, int] = (10, 5) follow_up: tuple[int, int] = (10, 5) follow_up_fails: bool = False + follow_up_breaks: bool = False + tool_rounds: int = 1 EVEN_ROUNDS: Final = Billing() @@ -138,6 +163,10 @@ def _fixed_turn(tool: str) -> Callable[[Mapping[str, JsonValue]], Turn]: return lambda _: Turn(tool, json.dumps(ADD), ANSWER) +def _slow_turn(tool: str, seconds: float) -> Callable[[Mapping[str, JsonValue]], Turn]: + return lambda _: Turn(tool, json.dumps({"seconds": seconds}), ANSWER) + + def _echoing_turn(body: Mapping[str, JsonValue]) -> Turn: names: Final = _tool_names(body) return Turn(names[0] if names else "", json.dumps({"query": _prompt(body)}), _tool_result_text(body) or "") @@ -183,6 +212,50 @@ def _item_events(index: int, item: Mapping[str, JsonValue]) -> tuple[dict[str, J ) +def _event_stream(events: Sequence[Mapping[str, JsonValue]]) -> Reply: + return Reply( + content_type="text/event-stream", + chunks=tuple( + f"event: {event['type']}\ndata: {json.dumps({**event, 'sequence_number': number})}\n\n".encode() + for number, event in enumerate(events) + ), + ) + + +def _broken_follow_up(target: str, identity: str, tokens: tuple[int, int]) -> Reply: + outage: Final = {"message": FOLLOW_UP_OUTAGE, "type": "server_error", "code": None} + if target.endswith("/chat/completions"): + delta: Final = {"role": "assistant", "content": "the sum"} + started: Final = { + "id": f"chatcmpl-{identity}", + "object": "chat.completion.chunk", + "created": 1, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": delta, "finish_reason": None}], + } + return Reply( + content_type="text/event-stream", + chunks=tuple(f"data: {json.dumps(frame)}\n\n".encode() for frame in (started, {"error": outage})), + ) + input_tokens, output_tokens = tokens + failed: Final[dict[str, JsonValue]] = { + "id": f"resp_{identity}", + "object": "response", + "created_at": 1, + "status": "failed", + "model": "gpt-4o-mini", + "output": [], + "error": {"code": "server_error", "message": FOLLOW_UP_OUTAGE}, + "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": sum(tokens)}, + } + return _event_stream( + ( + {"type": "response.created", "response": {**failed, "status": "in_progress", "error": None, "usage": None}}, + {"type": "response.failed", "response": failed}, + ) + ) + + def _responses_stream(response: Mapping[str, JsonValue], items: Sequence[Mapping[str, JsonValue]]) -> Reply: item_events: Final = tuple(chain.from_iterable(_item_events(index, item) for index, item in enumerate(items))) events: Final = ( @@ -191,13 +264,7 @@ def _responses_stream(response: Mapping[str, JsonValue], items: Sequence[Mapping *item_events, {"type": "response.completed", "response": response}, ) - return Reply( - content_type="text/event-stream", - chunks=tuple( - f"event: {event['type']}\ndata: {json.dumps({**event, 'sequence_number': number})}\n\n".encode() - for number, event in enumerate(events) - ), - ) + return _event_stream(events) def _block_ids(message: JsonValue, kind: str, field: str) -> frozenset[str]: @@ -252,6 +319,8 @@ def _anthropic_tool_result_rule(body: Mapping[str, JsonValue]) -> Reply | None: def _model_double( plan: Callable[[Mapping[str, JsonValue]], Turn], billing: Billing = EVEN_ROUNDS, parallel: int = 1 ) -> Callable[[Request], Reply]: + served: Final = count() + def respond(request: Request) -> Reply: if request.method == "GET" and request.target.endswith("/models"): return _json({"object": "list", "data": []}) @@ -260,11 +329,12 @@ def _model_double( outage: Final = {"error": {"message": "scripted provider outage", "type": "server_error", "code": None}} return Reply(status=500, body=json.dumps(outage).encode()) turn: Final = plan(body) + call_number: Final = next(served) answered: Final = _has_tool_result(body) if answered and billing.follow_up_fails: failure: Final = {"error": {"message": FOLLOW_UP_OUTAGE, "type": "server_error", "code": None}} return Reply(status=500, body=json.dumps(failure).encode()) - done: Final = answered or DIRECT in _prompt(body) + done: Final = DIRECT in _prompt(body) or (answered and call_number >= billing.tool_rounds) calls: Final = range(1, parallel + 1) prompt_tokens, completion_tokens = billing.follow_up if answered else billing.first identity: Final = uuid.uuid4().hex[:12] @@ -273,9 +343,15 @@ def _model_double( "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, } + if answered and billing.follow_up_breaks: + return _broken_follow_up(request.target, identity, (prompt_tokens, completion_tokens)) if request.target.endswith("/chat/completions"): requested: Final = tuple( - {"id": f"call_{call}", "type": "function", "function": {"name": turn.tool, "arguments": turn.arguments}} + { + "id": f"call_{identity}_{call}", + "type": "function", + "function": {"name": turn.tool, "arguments": turn.arguments}, + } for call in calls ) message: Final = ( @@ -460,6 +536,7 @@ def _upstream_model(surface: Surface) -> str: "responses": "openai/responses/gpt-4o-mini", "messages": "anthropic/claude-sonnet-4-5", "messages_bridge": "hosted_vllm/gpt-4o-mini", + "responses_bridge": "deepseek/gpt-4o-mini", }[surface] @@ -475,11 +552,15 @@ def _rig( billing: Billing = EVEN_ROUNDS, prices: Mapping[str, JsonValue] | None = None, parallel: int = 1, + slow_tool_seconds: float | None = None, ) -> Iterator[Rig]: alias: Final = "llm" + uuid.uuid4().hex[:8] + plan: Final = ( + _fixed_turn(f"{alias}-add") if slow_tool_seconds is None else _slow_turn(f"{alias}-slow", slow_tool_seconds) + ) with ( - mcp_peer() as peer, - wire_server(_model_double(_fixed_turn(f"{alias}-add"), billing, parallel)) as wire, + mcp_peer(rich=slow_tool_seconds is not None) as peer, + wire_server(_model_double(plan, billing, parallel)) as wire, gateway.scenario() as scenario, ): server_id: Final = register_mcp(scenario, peer, alias) @@ -1048,6 +1129,262 @@ def test_bridged_next_turn_replays_the_mcp_turn_answer(gateway: Gateway) -> None assert ANSWER in _assistant_texts(opening), opening +StreamedUpstream = Literal["messages_bridge", "responses_bridge"] +STREAMED_UPSTREAMS: Final[tuple[StreamedUpstream, ...]] = ("messages_bridge", "responses_bridge") +STREAMED_IDS: Final = ("native", "bridged") +SLOW_TOOL_SECONDS: Final = 5.0 + + +def _events(response: httpx.Response) -> tuple[dict[str, JsonValue], ...]: + return tuple( + JSON_OBJECT.validate_json(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ) + + +def _completed(response: httpx.Response) -> dict[str, JsonValue]: + finished: Final = tuple( + object_value(event["response"]) for event in _events(response) if event["type"] == "response.completed" + ) + assert len(finished) == 1, response.text + return finished[0] + + +def _model_rows(call_id: str, settled: Callable[[list[dict[str, JsonValue]]], bool]) -> list[dict[str, JsonValue]]: + return eventually( + lambda: read_rows( + "SELECT request_id, status, spend, prompt_tokens, completion_tokens " + 'FROM "LiteLLM_SpendLogs" WHERE litellm_call_id=%s AND call_type NOT IN (%s, %s)', + (call_id, "call_mcp_tool", "list_mcp_tools"), + ), + settled, + seconds=70, + return_last_on_timeout=True, + ) + + +def _billed(rows: Sequence[Mapping[str, JsonValue]]) -> float: + return sum(float(str(row["spend"])) for row in rows) + + +def _bills_at_least(expected: float) -> Callable[[list[dict[str, JsonValue]]], bool]: + return lambda rows: bool(rows) and _billed(rows) >= expected - 1e-9 + + +def _statuses(rows: Sequence[Mapping[str, JsonValue]]) -> list[str]: + return sorted(str(row["status"]) for row in rows) + + +def _logs_a_failure_billing(expected: float) -> Callable[[list[dict[str, JsonValue]]], bool]: + return lambda rows: "failure" in _statuses(rows) and _billed(rows) >= expected - 1e-9 + + +def _limited_key(rig: Rig) -> str: + return rig.scenario.key(object_permission={"mcp_servers": [rig.server_id]}, max_parallel_requests=1) + + +def _answers_directly(rig: Rig, key: str) -> int: + return rig.send(key, [AUTO], prompt=f"{DIRECT} {rig.alias}").status_code + + +STREAMED_ROUNDS: Final = ( + pytest.param("messages_bridge", 1, id="native"), + pytest.param("responses_bridge", 1, id="bridged"), + pytest.param("messages_bridge", 2, id="native-two-tool-rounds"), + pytest.param("responses_bridge", 2, id="bridged-two-tool-rounds"), + pytest.param("messages_bridge", 0, id="native-no-tool-call"), + pytest.param("responses_bridge", 0, id="bridged-no-tool-call"), +) + + +@pytest.mark.parametrize(("upstream", "tool_rounds"), STREAMED_ROUNDS) +def test_streaming_responses_auto_execute_bills_every_round_once_on_one_success_row( + gateway: Gateway, upstream: StreamedUpstream, tool_rounds: int +) -> None: + rounds: Final = (FIRST_ROUND, *(FOLLOW_UP_ROUND,) * tool_rounds) + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND, tool_rounds=max(tool_rounds, 1)) + with _rig(gateway, "responses", upstream=upstream, billing=billing, prices=PRICES) as rig: + prompt: Final = None if tool_rounds else f"{DIRECT} {rig.alias}" + response: Final = rig.send(_granted_key(rig), [AUTO], prompt=prompt, stream=True) + assert response.status_code == 200, response.text + completed: Final = _completed(response) + assert rig.final_text(completed) == ANSWER, response.text + assert len(rig.upstream_tools()) == len(rounds) + assert len(_peer_add_calls(rig.peer)) == tool_rounds + expected: Final = _price(*rounds) + FIXED_MARGIN + rows: Final = _model_rows(response.headers["x-litellm-call-id"], _bills_at_least(expected)) + assert [row["status"] for row in rows] == ["success"], rows + row: Final = rows[0] + assert (row["prompt_tokens"], row["completion_tokens"]) == ( + sum(prompt_tokens for prompt_tokens, _ in rounds), + sum(completion_tokens for _, completion_tokens in rounds), + ), row + usage: Final = object_value(completed["usage"]) + assert (usage["input_tokens"], usage["output_tokens"]) == (row["prompt_tokens"], row["completion_tokens"]) + assert float(str(row["spend"])) == pytest.approx(expected), row + + +@pytest.mark.parametrize("upstream", STREAMED_UPSTREAMS, ids=STREAMED_IDS) +def test_streaming_responses_rejected_follow_up_bills_round_one_once( + gateway: Gateway, upstream: StreamedUpstream +) -> None: + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND, follow_up_fails=True) + with _rig(gateway, "responses", upstream=upstream, billing=billing, prices=PRICES) as rig: + response: Final = rig.send(_granted_key(rig), [AUTO], stream=True) + assert FOLLOW_UP_OUTAGE in response.text, response.text + assert ANSWER not in response.text, response.text + assert len(rig.upstream_tools()) == 2 + expected: Final = _price(FIRST_ROUND) + FIXED_MARGIN + rows: Final = _model_rows(response.headers["x-litellm-call-id"], _bills_at_least(expected)) + assert _billed(rows) == pytest.approx(expected), rows + + +BROKEN_FOLLOW_UPS: Final = ( + pytest.param("messages_bridge", FOLLOW_UP_ROUND, id="native"), + pytest.param("responses_bridge", None, id="bridged"), +) + + +@pytest.mark.parametrize(("upstream", "reported_usage"), BROKEN_FOLLOW_UPS) +def test_streaming_responses_follow_up_broken_mid_stream_bills_round_one_once_and_itself_on_a_failure_row( + gateway: Gateway, upstream: StreamedUpstream, reported_usage: tuple[int, int] | None +) -> None: + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND, follow_up_breaks=True) + with _rig(gateway, "responses", upstream=upstream, billing=billing, prices=PRICES) as rig: + response: Final = rig.send(_granted_key(rig), [AUTO], stream=True) + assert ANSWER not in response.text, response.text + assert len(rig.upstream_tools()) == 2 + round_one: Final = _price(FIRST_ROUND) + FIXED_MARGIN + rows: Final = _model_rows(response.headers["x-litellm-call-id"], _logs_a_failure_billing(round_one)) + assert _statuses(rows) == ["failure", "success"], rows + (success,) = (row for row in rows if row["status"] == "success") + (failure,) = (row for row in rows if row["status"] == "failure") + assert (success["prompt_tokens"], success["completion_tokens"]) == FIRST_ROUND, rows + assert float(str(success["spend"])) == pytest.approx(round_one), rows + failed_round: Final = (int(str(failure["prompt_tokens"])), int(str(failure["completion_tokens"]))) + assert min(failed_round) > 0, rows + assert reported_usage in (None, failed_round), rows + assert float(str(failure["spend"])) == pytest.approx(_price(failed_round)), rows + + +@pytest.mark.parametrize("upstream", STREAMED_UPSTREAMS, ids=STREAMED_IDS) +def test_streaming_responses_blocked_by_a_post_call_guardrail_bills_every_round_once( + gateway: Gateway, upstream: StreamedUpstream +) -> None: + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND) + with _rig(gateway, "responses", upstream=upstream, billing=billing, prices=PRICES) as rig: + key: Final = _granted_key(rig) + one_round: Final = rig.send(key, [], prompt=f"{DIRECT} {rig.alias}", stream=True, guardrails=[STREAM_BLOCK]) + assert STREAM_BLOCK_DETAIL in one_round.text, one_round.text + one_round_rows: Final = _model_rows( + one_round.headers["x-litellm-call-id"], _logs_a_failure_billing(_price(FIRST_ROUND) + FIXED_MARGIN) + ) + rig.wire.drain() + response: Final = rig.send(key, [AUTO], stream=True, guardrails=[STREAM_BLOCK]) + assert STREAM_BLOCK_DETAIL in response.text, response.text + assert len(rig.upstream_tools()) == 2 and len(_peer_add_calls(rig.peer)) == 1 + expected: Final = _price(FIRST_ROUND, FOLLOW_UP_ROUND) + FIXED_MARGIN + rows: Final = _model_rows(response.headers["x-litellm-call-id"], _logs_a_failure_billing(expected)) + assert _statuses(rows) == _statuses(one_round_rows), (rows, one_round_rows) + assert _billed(rows) == pytest.approx(expected), rows + + +@pytest.mark.parametrize( + ("surface", "stream"), + (("responses", True), ("responses", False), ("chat", False)), + ids=("responses-stream", "responses", "chat"), +) +def test_auto_executed_tool_call_runs_inside_the_requests_one_parallel_slot( + gateway: Gateway, surface: Surface, stream: bool +) -> None: + with _rig(gateway, surface, slow_tool_seconds=SLOW_TOOL_SECONDS) as rig: + key: Final = _limited_key(rig) + with ThreadPoolExecutor(max_workers=1) as pool: + running: Final = pool.submit(rig.send, key, [AUTO], None, stream=stream) + eventually(lambda: _peer_add_calls(rig.peer), lambda found: len(found) == 1, seconds=30) + during: Final = _answers_directly(rig, key) + served: Final = running.result(timeout=90) + assert during == 429 + assert served.status_code == 200 and ANSWER in served.text, served.text + eventually(lambda: _answers_directly(rig, key), lambda status: status == 200, seconds=30) + + +@pytest.mark.parametrize("upstream", STREAMED_UPSTREAMS, ids=STREAMED_IDS) +def test_streaming_responses_hung_up_during_the_tool_call_bills_each_served_round_once_and_frees_the_slot( + gateway: Gateway, upstream: StreamedUpstream +) -> None: + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND) + with _rig( + gateway, + "responses", + upstream=upstream, + billing=billing, + prices=PRICES, + slow_tool_seconds=SLOW_TOOL_SECONDS, + ) as rig: + key: Final = _limited_key(rig) + with rig.gateway.client.stream( + "POST", + "/v1/responses", + headers={"Authorization": f"Bearer {key}"}, + json={"model": rig.model, "input": f"add {rig.alias}", "tools": [AUTO], "stream": True}, + timeout=90, + ) as hung_up: + assert hung_up.status_code == 200 + call_id: Final = hung_up.headers["x-litellm-call-id"] + eventually(lambda: _peer_add_calls(rig.peer), lambda found: len(found) == 1, seconds=30) + eventually(lambda: _answers_directly(rig, key), lambda status: status == 200, seconds=60) + posted: Final = tuple( + JSON_OBJECT.validate_json(request.body) for request in rig.wire.drain() if request.method == "POST" + ) + served: Final = tuple( + FOLLOW_UP_ROUND if _has_tool_result(body) else FIRST_ROUND for body in posted if DIRECT not in _prompt(body) + ) + expected: Final = _price(*served) + FIXED_MARGIN + rows: Final = _model_rows(call_id, _bills_at_least(expected)) + assert served and rows, (served, rows) + assert _billed(rows) == pytest.approx(expected), (served, rows) + + +@pytest.mark.parametrize("upstream", STREAMED_UPSTREAMS, ids=STREAMED_IDS) +def test_streaming_responses_repeated_from_the_response_cache_bills_nothing( + gateway: Gateway, upstream: StreamedUpstream +) -> None: + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND) + with _rig(gateway, "responses", upstream=upstream, billing=billing, prices=PRICES) as rig: + key: Final = _granted_key(rig) + paid: Final = rig.send(key, [AUTO], stream=True) + assert paid.status_code == 200 and rig.final_text(_completed(paid)) == ANSWER, paid.text + paid_price: Final = _price(FIRST_ROUND, FOLLOW_UP_ROUND) + FIXED_MARGIN + _model_rows(paid.headers["x-litellm-call-id"], _bills_at_least(paid_price)) + assert len(rig.upstream_tools()) == 2 + cached: Final = rig.send(key, [AUTO], stream=True) + assert cached.status_code == 200 and rig.final_text(_completed(cached)) == ANSWER, cached.text + assert rig.upstream_tools() == () + rows: Final = _model_rows( + cached.headers["x-litellm-call-id"], lambda found: "success" in {row["status"] for row in found} + ) + assert "success" in {row["status"] for row in rows} and _billed(rows) == 0.0, rows + + +def test_bridged_next_turn_after_a_streamed_mcp_turn_replays_its_answer(gateway: Gateway) -> None: + billing: Final = Billing(FIRST_ROUND, FOLLOW_UP_ROUND) + with _rig(gateway, "responses", upstream="responses_bridge", billing=billing, prices=PRICES) as rig: + key: Final = _granted_key(rig) + first: Final = rig.send(key, [AUTO], stream=True) + assert first.status_code == 200, first.text + first_id: Final = string_value(_completed(first)["id"]) + _model_rows(first.headers["x-litellm-call-id"], _bills_at_least(_price(FIRST_ROUND, FOLLOW_UP_ROUND))) + rig.wire.drain() + second: Final = rig.send(key, [AUTO], previous_response_id=first_id, cache={"no-cache": True}) + assert second.status_code == 200, second.text + opening: Final = next( + JSON_OBJECT.validate_json(request.body) for request in rig.wire.drain() if request.method == "POST" + ) + assert ANSWER in _assistant_texts(opening), opening + + Bridge = Literal["chat", "responses", "messages"] BRIDGES: Final[tuple[Bridge, ...]] = ("chat", "responses") Client = Literal["sync", "async"] diff --git a/tests/integration/spend/test_global_proxy_spend_counter.py b/tests/integration/spend/test_global_proxy_spend_counter.py index 0185e48ffbd..e818487cf53 100644 --- a/tests/integration/spend/test_global_proxy_spend_counter.py +++ b/tests/integration/spend/test_global_proxy_spend_counter.py @@ -1,16 +1,113 @@ +import json import os import uuid +from itertools import product +from pathlib import Path from typing import Final +import httpx import pytest +import yaml +from integration._support.client import Gateway, eventually, object_value +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.redis_process import owned_redis +from pydantic import JsonValue, TypeAdapter +from redis import Redis import litellm from litellm.caching.in_memory_cache import InMemoryCache from litellm.caching.redis_cache import RedisCache -from litellm.constants import GLOBAL_PROXY_SPEND_CACHE_KEY +from litellm.constants import GLOBAL_PROXY_SPEND_CACHE_KEY, LITELLM_PROXY_BUDGET_NAME from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.proxy_server import update_cache +BUDGET_MODEL: Final = "proxy-budget-model" +CONFIG: Final = TypeAdapter(dict[str, JsonValue]) +SECTION: Final = TypeAdapter(dict[str, JsonValue]) +OWNED_PROXY_SECONDS: Final = 2 * graceful_stop_seconds() + 120 + + +def _proxy_budget_config(gateway: Gateway, directory: Path, max_budget: float) -> Path: + loaded: Final = CONFIG.validate_python( + yaml.safe_load((Path(__file__).resolve().parents[1] / "proxy_config.yaml").read_text()) + ) + model: Final[JsonValue] = { + "model_name": BUDGET_MODEL, + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "integration-provider-key", + "api_base": f"{gateway.upstream_url}/v1", + "input_cost_per_token": 0.001, + "output_cost_per_token": 0.002, + }, + } + path: Final = directory / "proxy_budget_config.yaml" + path.write_text( + yaml.safe_dump( + { + **loaded, + "model_list": [model], + "general_settings": { + **SECTION.validate_python(loaded["general_settings"]), + "user_api_key_cache_ttl": 600, + }, + "litellm_settings": { + **SECTION.validate_python(loaded["litellm_settings"]), + "max_budget": max_budget, + "budget_duration": "30d", + }, + } + ) + ) + return path + + +def _chat(proxy: Gateway, key: str, marker: str) -> httpx.Response: + return proxy.request( + "POST", + "/v1/chat/completions", + {"model": BUDGET_MODEL, "max_tokens": 20, "messages": [{"role": "user", "content": marker}]}, + key=key, + ) + + +def _served_id(proxy: Gateway, key: str) -> str: + response: Final = _chat(proxy, key, f"proxy budget {uuid.uuid4().hex}") + assert response.status_code == 200, response.text + identity: Final = response.json()["id"] + assert isinstance(identity, str), response.text + return identity + + +def _logged_spend(proxy: Gateway, request_ids: tuple[str, ...]) -> tuple[float, ...]: + rows: Final = eventually( + lambda: proxy.rows( + 'SELECT spend FROM "LiteLLM_SpendLogs" WHERE request_id = ANY(%s::text[])', + ("{" + ",".join(request_ids) + "}",), + ), + lambda found: len(found) == len(request_ids), + seconds=70, + ) + return tuple(float(str(row["spend"])) for row in rows) + + +def _proxy_budget_spend(proxy: Gateway) -> float | None: + rows: Final = proxy.rows('SELECT spend FROM "LiteLLM_UserTable" WHERE user_id = %s', (LITELLM_PROXY_BUDGET_NAME,)) + return float(str(rows[0]["spend"])) if rows else None + + +def _counter_key(redis: Redis) -> str: + found: Final = tuple(str(key, "utf-8") for key in redis.scan_iter(match=f"*{GLOBAL_PROXY_SPEND_CACHE_KEY}")) + assert len(found) == 1, found + return found[0] + + +def _upstream_saw(proxy: Gateway, marker: str) -> bool: + with httpx.Client(base_url=proxy.upstream_url, timeout=5, trust_env=False) as upstream: + response: Final = upstream.get("/__observations") + response.raise_for_status() + return marker in json.dumps(object_value(response.json())["requests"]) + async def _add_request_cost(cache: UserApiKeyCache, response_cost: float) -> None: await update_cache( @@ -56,3 +153,63 @@ async def test_global_proxy_spend_adds_every_instance_cost_and_stays_cleared_aft assert await redis_cache.async_get_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY) is None finally: await redis_cache.async_delete_cache(key=GLOBAL_PROXY_SPEND_CACHE_KEY) + + +@pytest.mark.timeout(OWNED_PROXY_SECONDS) +def test_a_key_with_no_user_spends_toward_the_proxy_max_budget(gateway: Gateway, tmp_path: Path) -> None: + with ( + owned_proxy(gateway, tmp_path, {}, config=_proxy_budget_config(gateway, tmp_path, max_budget=0.01)) as owned, + owned.scenario() as scenario, + ): + key: Final = scenario.key(models=[BUDGET_MODEL]) + (spent,) = _logged_spend(owned, (_served_id(owned, key),)) + assert spent > 0.01, spent + eventually( + lambda: _proxy_budget_spend(owned), + lambda recorded: recorded is not None and recorded > 0.01, + seconds=70, + ) + + marker: Final = f"over the proxy budget {uuid.uuid4().hex}" + refused: Final = _chat(owned, key, marker) + + assert refused.status_code != 200, refused.text + assert object_value(refused.json()["error"])["type"] == "budget_exceeded", refused.text + assert not _upstream_saw(owned, marker), refused.text + + +@pytest.mark.timeout(2 * OWNED_PROXY_SECONDS) +def test_two_proxies_add_every_request_to_one_proxy_spend_counter(gateway: Gateway, tmp_path: Path) -> None: + config: Final = _proxy_budget_config(gateway, tmp_path, max_budget=1000.0) + with owned_redis(tmp_path / "redis") as cache, Redis(host=cache.host, port=cache.port) as redis: + shared_redis: Final = {"REDIS_HOST": cache.host, "REDIS_PORT": str(cache.port)} + with owned_proxy(gateway, tmp_path / "first", shared_redis, config=config) as first: + assert first.database_url is not None + with ( + owned_proxy( + gateway, tmp_path / "second", {**shared_redis, "DATABASE_URL": first.database_url}, config=config + ) as second, + first.scenario() as scenario, + ): + user_key: Final = scenario.key(user_id=scenario.user(), models=[BUDGET_MODEL]) + userless_key: Final = scenario.key(models=[BUDGET_MODEL]) + counter: Final = _counter_key(redis) + ttl_before: Final = redis.ttl(counter) + + request_ids: Final = tuple( + _served_id(proxy, key) for key, proxy in product((user_key, userless_key), (first, second)) + ) + total: Final = sum(_logged_spend(first, request_ids)) + persisted: Final = eventually( + lambda: _proxy_budget_spend(first), + lambda spend: spend is not None and spend == pytest.approx(total), + seconds=70, + ) + + assert persisted == pytest.approx(total) + eventually( + lambda: redis.get(counter), + lambda shared: shared is not None and float(str(shared, "utf-8")) == pytest.approx(total), + seconds=70, + ) + assert 0 < redis.ttl(counter) <= ttl_before diff --git a/tests/unit/images/test_image_edit.py b/tests/unit/images/test_image_edit.py index fb7a2869b2a..4f858050e3e 100644 --- a/tests/unit/images/test_image_edit.py +++ b/tests/unit/images/test_image_edit.py @@ -105,6 +105,24 @@ def test_openai_image_edit_sync_accepts_bytesio_images(respx_mock: respx.MockRou assert _multipart_image_parts(route.calls[0].request) == (_FIRST_IMAGE,) +def test_openai_image_edit_without_a_model_leaves_model_out_of_the_request( + respx_mock: respx.MockRouter, httpx_transport: None +) -> None: + route: Final = respx_mock.post("https://api.openai.com/v1/images/edits").mock( + return_value=httpx.Response(200, json=_EDIT_RESPONSE) + ) + + litellm.image_edit( + prompt="combine the reference images", + image=io.BytesIO(_FIRST_IMAGE), + custom_llm_provider="openai", + api_key="fake-key", + ) + + assert route.call_count == 1 + assert b'name="model"' not in route.calls[0].request.read() + + @pytest.mark.asyncio async def test_openai_image_edit_accepts_bytesio_images(respx_mock: respx.MockRouter, httpx_transport: None) -> None: route: Final = respx_mock.post("https://api.openai.com/v1/images/edits").mock( diff --git a/tests/unit/images/test_main.py b/tests/unit/images/test_main.py index 29424e50079..61b2f28b727 100644 --- a/tests/unit/images/test_main.py +++ b/tests/unit/images/test_main.py @@ -300,7 +300,7 @@ async def test_aimage_generation_ignores_a_non_string_base_url_like_the_sync_pat ) response: Final = await litellm.aimage_generation( - model="dall-e-3", + model="gpt-image-2.5-sunburst", prompt="a red circle", api_base=api_base, base_url=httpx.URL(api_base), @@ -688,3 +688,24 @@ async def test_chatgpt_images_forward_client_login_and_workspace( assert "cookie" not in request.headers assert "x-litellm-api-key" not in request.headers assert json.loads(request.content) == {"model": "gpt-image-2", "prompt": "A blue circle"} + + +def test_image_generation_without_a_model_or_provider_fails_before_calling_any_provider( + respx_mock: respx.MockRouter, +) -> None: + with pytest.raises(litellm.BadRequestError, match="model is required"): + litellm.image_generation(prompt="a red circle", api_key="fake_openai_api_key") + + assert not respx_mock.calls + + +def test_topaz_image_variation_works_without_a_model(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post("https://api.topazlabs.com/image/v1/enhance").mock( + return_value=httpx.Response(200, content=b"enhanced-image", headers={"content-type": "image/png"}) + ) + + response: Final = litellm.image_variation(image=b"source-image", custom_llm_provider="topaz", api_key="fake-key") + + assert route.call_count == 1 + assert response.data is not None + assert response.data[0].b64_json == base64.b64encode(b"enhanced-image").decode() diff --git a/tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py b/tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py index eabd5c8427d..83e01fce642 100644 --- a/tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py +++ b/tests/unit/llms/azure/image_generation/test_azure_image_generation_init.py @@ -20,27 +20,9 @@ from litellm.llms.azure.image_generation.http_utils import ( azure_deployment_image_generation_json_body, ) from litellm.llms.custom_httpx.http_handler import HTTPHandler -from litellm.llms.azure.image_generation import ( - AzureDallE3ImageGenerationConfig, - get_azure_image_generation_config, -) from litellm.utils import get_optional_params_image_gen -@pytest.mark.parametrize( - "received_model, expected_config", - [ - ("dall-e-3", AzureDallE3ImageGenerationConfig), - ("dalle-3", AzureDallE3ImageGenerationConfig), - ("openai_dall_e_3", AzureDallE3ImageGenerationConfig), - ], -) -def test_azure_image_generation_config(received_model, expected_config): - assert isinstance( - get_azure_image_generation_config(received_model), expected_config - ) - - def test_azure_deployment_image_generation_json_body(): """Deployment-scoped Azure image URL must not send ``model`` in JSON.""" api = ( diff --git a/tests/unit/llms/openai/image_generation/test_openai_image_generation_extra_headers.py b/tests/unit/llms/openai/image_generation/test_openai_image_generation_extra_headers.py index 11df07f5fea..e103412e0c3 100644 --- a/tests/unit/llms/openai/image_generation/test_openai_image_generation_extra_headers.py +++ b/tests/unit/llms/openai/image_generation/test_openai_image_generation_extra_headers.py @@ -265,7 +265,7 @@ class TestImageGenerationEntryPointHeaders: test_headers = {"cf-aig-authorization": "Bearer my-secret"} await litellm.aimage_generation( - model="dall-e-3", + model="gpt-image-2.5-sunburst", prompt="A white cat", extra_headers=test_headers, client=mock_openai_client, diff --git a/tests/unit/llms/openai/test_openai_common_utils.py b/tests/unit/llms/openai/test_openai_common_utils.py index 831df30c98a..8dc92e23fdf 100644 --- a/tests/unit/llms/openai/test_openai_common_utils.py +++ b/tests/unit/llms/openai/test_openai_common_utils.py @@ -46,12 +46,12 @@ API_FUNCTION_PARAMS = [ ( "image_generation", False, - {"model": "dall-e-3", "prompt": "A beautiful sunset over mountains"}, + {"model": "gpt-image-2.5-sunburst", "prompt": "A beautiful sunset over mountains"}, ), ( "image_generation", True, - {"model": "dall-e-3", "prompt": "A beautiful sunset over mountains"}, + {"model": "gpt-image-2.5-sunburst", "prompt": "A beautiful sunset over mountains"}, ), ( "speech", @@ -84,11 +84,7 @@ async def test_openai_client_reuse(function_name, is_async, args): """ # Determine which client class to mock based on whether the test is async - client_path = ( - "litellm.llms.openai.openai.AsyncOpenAI" - if is_async - else "litellm.llms.openai.openai.OpenAI" - ) + client_path = "litellm.llms.openai.openai.AsyncOpenAI" if is_async else "litellm.llms.openai.openai.OpenAI" # Create the appropriate patches with ( @@ -98,9 +94,7 @@ async def test_openai_client_reuse(function_name, is_async, args): ): # Setup the mock to return None first time (cache miss) then a client for subsequent calls mock_client = MagicMock() - mock_get_cache.side_effect = [None] + [ - mock_client - ] * 9 # First call returns None, rest return the mock client + mock_get_cache.side_effect = [None] + [mock_client] * 9 # First call returns None, rest return the mock client # Make 10 API calls for _ in range(10): @@ -118,9 +112,9 @@ async def test_openai_client_reuse(function_name, is_async, args): pass # Verify client was created only once - assert ( - mock_client_class.call_count == 1 - ), f"{'Async' if is_async else ''}OpenAI client should be created only once" + assert mock_client_class.call_count == 1, ( + f"{'Async' if is_async else ''}OpenAI client should be created only once" + ) # Verify the client was cached assert mock_set_cache.call_count == 1, "Client should be cached once" @@ -144,12 +138,8 @@ def test_precomputed_init_params_match_inspect_signature(): _OPENAI_INIT_PARAMS, ) - expected_openai = tuple( - p for p in inspect.signature(OpenAI.__init__).parameters if p != "self" - ) - expected_azure = tuple( - p for p in inspect.signature(AzureOpenAI.__init__).parameters if p != "self" - ) + expected_openai = tuple(p for p in inspect.signature(OpenAI.__init__).parameters if p != "self") + expected_azure = tuple(p for p in inspect.signature(AzureOpenAI.__init__).parameters if p != "self") assert _OPENAI_INIT_PARAMS == expected_openai assert _AZURE_OPENAI_INIT_PARAMS == expected_azure @@ -500,7 +490,10 @@ async def test_sdk_api_factories_keep_httpx_transport_and_request_timeouts(api: moved: Final = mock.get(host="sdk-default.example", path="/v1/moved-models").mock( return_value=httpx.Response( 200, - json={"object": "list", "data": [{"id": "sdk-compat", "object": "model", "created": 0, "owned_by": "t"}]}, + json={ + "object": "list", + "data": [{"id": "sdk-compat", "object": "model", "created": 0, "owned_by": "t"}], + }, ) ) sdk_client: Final = _sdk_api_client(api, is_async, timeout) @@ -542,7 +535,10 @@ async def test_azure_gateway_and_image_variations_use_the_callers_async_session( assert azure_client is not None assert azure_client._client is session response: Final = await aimage_variation( - image=BytesIO(b"image-bytes"), api_key="transport-only", api_base="https://sdk-default.example/v1" + image=BytesIO(b"image-bytes"), + custom_llm_provider="openai", + api_key="transport-only", + api_base="https://sdk-default.example/v1", ) assert response.data[0].url == "https://example.com/image.png" assert not session.is_closed diff --git a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py index 6c10d524531..5916ce9f7a1 100644 --- a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py +++ b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_messages_config.py @@ -6,12 +6,14 @@ from datetime import datetime, timedelta from queue import SimpleQueue from threading import get_ident from typing import Final -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest import requests import responses +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.messages.transformation import ( VertexAIPartnerModelsAnthropicMessagesConfig, ) @@ -94,6 +96,85 @@ async def test_messages_auth_load_and_refresh_leave_the_event_loop(stream: bool, assert headers == {"Authorization": "Bearer incoming", "anthropic-beta": "retained-beta", "x-request-id": "req-1"} +def _vertex_client() -> MagicMock: + vertex_response: Final = MagicMock() + vertex_response.json.return_value = { + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hi"}], + "model": "claude-sonnet-5", + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + client: Final = MagicMock(spec=AsyncHTTPHandler) + client.post = AsyncMock(return_value=vertex_response) + return client + + +async def _send_to_vertex(client: MagicMock, caller_headers: Mapping[str, str], drop_params: bool) -> None: + with responses.RequestsMock(assert_all_requests_are_fired=False) as oauth: + oauth.add(responses.POST, "https://oauth2.googleapis.com/token", json={"access_token": "vertex-token", "expires_in": 3600}) + await litellm.anthropic.messages.acreate( + model="vertex_ai/claude-sonnet-5", + messages=[{"role": "user", "content": "hi"}], + max_tokens=16, + vertex_ai_project="version-project", + vertex_ai_location="global", + vertex_credentials=json.dumps( + { + "type": "authorized_user", + "client_id": "version-header-client", + "client_secret": "synthetic-secret", + "refresh_token": "synthetic-refresh", + } + ), + client=client, + drop_params=drop_params, + provider_specific_header={ + "custom_llm_provider": "vertex_ai", + "extra_headers": {**caller_headers, "x-request-id": "req-1"}, + }, + ) + + +@pytest.mark.parametrize( + ("caller_headers", "drop_params", "body_version"), + ( + ({"anthropic-version": "2026-10-21"}, False, "2026-10-21"), + ({"Anthropic-Version": "2026-10-21"}, False, "2026-10-21"), + ({"anthropic-version": "vertex-2023-10-16"}, False, "vertex-2023-10-16"), + ({"anthropic-version": "2023-06-01"}, False, "vertex-2023-10-16"), + ({"anthropic-version": "2023-01-01"}, False, "vertex-2023-10-16"), + ({"anthropic-version": " "}, False, "vertex-2023-10-16"), + ({}, False, "vertex-2023-10-16"), + ({"anthropic-version": "latest"}, True, "vertex-2023-10-16"), + ), +) +@pytest.mark.asyncio +async def test_caller_anthropic_version_reaches_vertex_only_in_the_body( + caller_headers: dict[str, str], drop_params: bool, body_version: str +) -> None: + client: Final = _vertex_client() + await _send_to_vertex(client, caller_headers, drop_params) + + sent: Final = client.post.call_args.kwargs + sent_header_names: Final = {name.lower() for name in sent["headers"]} + assert "anthropic-version" not in sent_header_names + assert sent["headers"]["x-request-id"] == "req-1" + assert json.loads(sent["data"])["anthropic_version"] == body_version + + +@pytest.mark.parametrize("requested", ("2025-01-01", "latest")) +@pytest.mark.asyncio +async def test_unsupported_anthropic_version_is_refused_without_drop_params(requested: str) -> None: + client: Final = _vertex_client() + with pytest.raises(litellm.BadRequestError, match=f"anthropic-version={requested}. To drop unsupported params"): + await _send_to_vertex(client, {"anthropic-version": requested}, drop_params=False) + client.post.assert_not_awaited() + + def test_validate_environment_uses_vertex_ai_location(): config = VertexAIPartnerModelsAnthropicMessagesConfig() headers = {}