Merge branch 'main' into litellm_mcp_admin_team_key_scope

This commit is contained in:
mateo-berri 2026-10-28 18:19:25 -07:00
commit c84141f7b2
45 changed files with 1138 additions and 691 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 .

View file

@ -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",

View file

@ -1,13 +1,25 @@
pub fn parse_str_bool(value: &str) -> Option<bool> {
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<bool> {
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<bool> {
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<bool>,
) {
assert_eq!(parse_pydantic_bool(input), expected, "{input:?}");
}
}

View file

@ -37,6 +37,7 @@ pub fn build_inference(config: &Config) -> Result<Arc<Gateway>, 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<Arc<Gateway>, 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<RedisClients, Error> {

View file

@ -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<String> {
(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(

View file

@ -77,6 +77,15 @@ pub trait BaseMessagesConfig: Sync {
headers
}
fn request_body(
&self,
request: MessagesRequest,
_headers: &Headers,
_drop_params: bool,
) -> Result<MessagesRequest, Error> {
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 {

View file

@ -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

View file

@ -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<MessagesRequest, Error> {
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);

View file

@ -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

View file

@ -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

View file

@ -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<String, Deployment>);
fn is_on(flag: Option<&Spelled<bool>>, 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
);
}
}

View file

@ -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()
);

View file

@ -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"]

View file

@ -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)

View file

@ -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)

View file

@ -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,
)

View file

@ -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
):

View file

@ -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,

View file

@ -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(

View file

@ -1,7 +0,0 @@
from litellm.llms.openai.image_generation import DallE2ImageGenerationConfig
class AzureDallE2ImageGenerationConfig(DallE2ImageGenerationConfig):
"""
Azure dall-e-2 image generation config
"""

View file

@ -1,7 +0,0 @@
from litellm.llms.openai.image_generation import DallE3ImageGenerationConfig
class AzureDallE3ImageGenerationConfig(DallE3ImageGenerationConfig):
"""
Azure dall-e-3 image generation config
"""

View file

@ -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()

View file

@ -1,7 +0,0 @@
from litellm.llms.openai.image_generation import DallE2ImageGenerationConfig
class AzureFoundryDallE2ImageGenerationConfig(DallE2ImageGenerationConfig):
"""
Azure dall-e-2 image generation config
"""

View file

@ -1,7 +0,0 @@
from litellm.llms.openai.image_generation import DallE3ImageGenerationConfig
class AzureFoundryDallE3ImageGenerationConfig(DallE3ImageGenerationConfig):
"""
Azure dall-e-3 image generation config
"""

View file

@ -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),
)

View file

@ -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()

View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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(

View file

@ -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()

View file

@ -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 = (

View file

@ -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,

View file

@ -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

View file

@ -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 = {}