This commit is contained in:
Nate Armstrong 2026-10-19 03:52:46 +00:00 • committed by GitHub
commit 0961bba125
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1030 additions and 44 deletions

View file

@ -4098,6 +4098,7 @@ dependencies = [
"litellm-inference-testing",
"litellm-llms",
"litellm-llms-types",
"litellm-router-types",
"litellm-secrets",
"litellm-tracing",
"reqwest 0.12.28",

View file

@ -532,7 +532,7 @@ pub fn bedrock_model_id_and_region(model: &str) -> (String, Option<String>) {
(stripped.to_string(), region)
}
fn is_bedrock_region(value: &str) -> bool {
pub fn is_bedrock_region(value: &str) -> bool {
value.len() > 3
&& value.contains('-')
&& value

View file

@ -36,6 +36,7 @@ pub(crate) async fn create(
api_base: deployment.api_base.clone(),
custom_llm_provider: deployment.custom_llm_provider.clone(),
api_version: deployment.litellm_params.api_version.clone(),
litellm_params: deployment.litellm_params.clone(),
extra_headers: None,
timeout: deployment.timeout,
};

View file

@ -17,6 +17,7 @@ litellm-http.workspace = true
litellm-inference.workspace = true
litellm-llms.workspace = true
litellm-llms-types.workspace = true
litellm-router-types.workspace = true
litellm-secrets.workspace = true
reqwest.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }

View file

@ -5,6 +5,9 @@ use litellm_host::interceptors::{ProviderIdentity, RequestContext};
use litellm_llms::{
azure::responses::transformation::AZURE_RESPONSES_CONFIG,
base_llm::responses::transformation::BaseResponsesApiConfig,
bedrock_mantle::responses::transformation::{
BedrockMantleResponsesApiConfig, split_region_prefix,
},
databricks::responses::transformation::DATABRICKS_RESPONSES_CONFIG,
fireworks_ai::responses::transformation::FIREWORKS_AI_RESPONSES_CONFIG,
hosted_vllm::responses::transformation::HOSTED_VLLM_RESPONSES_CONFIG,
@ -12,6 +15,7 @@ use litellm_llms::{
openrouter::responses::transformation::OPENROUTER_RESPONSES_CONFIG,
xai::responses::transformation::XAI_RESPONSES_CONFIG,
};
use litellm_router_types::LitellmParams;
use litellm_secrets::source::SecretSource;
use serde_json::Value;
@ -25,8 +29,12 @@ pub(super) async fn prepare(
call: ResponsesCall,
secrets: &dyn SecretSource,
) -> Result<ProviderResponsesRequest, Error> {
let ResponsesProvider { identity, config } =
resolve_provider(&call.model, call.custom_llm_provider.as_deref())?;
let ResponsesProvider { identity, config } = resolve_provider(
&call.model,
call.custom_llm_provider.as_deref(),
call.api_base.as_deref(),
&call.litellm_params,
)?;
let provider = identity.provider.as_str();
let model = identity.model.as_str();
let snapshot = secrets
@ -67,7 +75,7 @@ pub(super) async fn prepare(
pub(super) struct ResponsesProvider {
pub identity: ProviderIdentity,
pub config: &'static dyn BaseResponsesApiConfig,
pub config: Box<dyn BaseResponsesApiConfig + Send>,
}
/// Python's `get_llm_provider`: a bare model is OpenAI's, and with an explicit OpenRouter
@ -75,6 +83,8 @@ pub(super) struct ResponsesProvider {
pub(super) fn resolve_provider(
model: &str,
custom_llm_provider: Option<&str>,
api_base: Option<&str>,
params: &LitellmParams,
) -> Result<ResponsesProvider, Error> {
let explicit = custom_llm_provider.filter(|provider| !provider.is_empty());
let (provider, resolved) = match get_custom_llm_provider(model, explicit) {
@ -87,24 +97,34 @@ pub(super) fn resolve_provider(
if resolved.is_empty() {
return Err(Error::InvalidProvider(model.into()));
}
let (config, wire_model): (&'static dyn BaseResponsesApiConfig, String) =
match provider.parse::<LlmProviders>() {
Ok(LlmProviders::Openai) if resolved.contains('/') => {
return Err(Error::InvalidProvider(model.into()));
}
Ok(LlmProviders::Openai) => (&OpenAiResponsesApiConfig, resolved.into()),
Ok(LlmProviders::Openrouter) if explicit.is_some() && !resolved.contains('/') => (
&OPENROUTER_RESPONSES_CONFIG,
format!("openrouter/{resolved}"),
),
Ok(LlmProviders::Openrouter) => (&OPENROUTER_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::Azure) => (&AZURE_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::Xai) => (&XAI_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::Databricks) => (&DATABRICKS_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::FireworksAi) => (&FIREWORKS_AI_RESPONSES_CONFIG, resolved.into()),
Ok(LlmProviders::HostedVllm) => (&HOSTED_VLLM_RESPONSES_CONFIG, resolved.into()),
_ => return Err(Error::Unsupported("native HTTP responses provider")),
};
let (config, wire_model): (Box<dyn BaseResponsesApiConfig + Send>, String) = match provider
.parse::<LlmProviders>(
) {
Ok(LlmProviders::Openai) if resolved.contains('/') => {
return Err(Error::InvalidProvider(model.into()));
}
Ok(LlmProviders::Openai) => (Box::new(OpenAiResponsesApiConfig), resolved.into()),
Ok(LlmProviders::Openrouter) if explicit.is_some() && !resolved.contains('/') => (
Box::new(OPENROUTER_RESPONSES_CONFIG),
format!("openrouter/{resolved}"),
),
Ok(LlmProviders::Openrouter) => (Box::new(OPENROUTER_RESPONSES_CONFIG), resolved.into()),
Ok(LlmProviders::Azure) => (Box::new(AZURE_RESPONSES_CONFIG), resolved.into()),
Ok(LlmProviders::Xai) => (Box::new(XAI_RESPONSES_CONFIG), resolved.into()),
Ok(LlmProviders::Databricks) => (Box::new(DATABRICKS_RESPONSES_CONFIG), resolved.into()),
Ok(LlmProviders::FireworksAi) => (Box::new(FIREWORKS_AI_RESPONSES_CONFIG), resolved.into()),
Ok(LlmProviders::HostedVllm) => (Box::new(HOSTED_VLLM_RESPONSES_CONFIG), resolved.into()),
Ok(LlmProviders::BedrockMantle) => {
let (region, wire) = split_region_prefix(resolved);
(
Box::new(BedrockMantleResponsesApiConfig::new(
api_base, region, params,
)),
wire.into(),
)
}
_ => return Err(Error::Unsupported("native HTTP responses provider")),
};
Ok(ResponsesProvider {
identity: ProviderIdentity {
model: wire_model,
@ -165,6 +185,12 @@ mod tests {
"accounts/fireworks/models/qwen3"
)]
#[case::xai_prefix("xai/grok-4", None, "xai", "grok-4")]
#[case::bedrock_mantle_region_prefix(
"bedrock_mantle/us-west-2/openai.gpt-5.5",
None,
"bedrock_mantle",
"openai.gpt-5.5"
)]
#[case::hosted_vllm_keeps_the_vendor(
"hosted_vllm/meta-llama/Llama-3.1-8B",
None,
@ -177,7 +203,8 @@ mod tests {
#[case] provider: &str,
#[case] wire_model: &str,
) {
let resolved = resolve_provider(model, custom_llm_provider).unwrap();
let resolved =
resolve_provider(model, custom_llm_provider, None, &LitellmParams::default()).unwrap();
assert_eq!(resolved.identity.provider, provider);
assert_eq!(resolved.identity.model, wire_model);
}
@ -190,7 +217,7 @@ mod tests {
#[case::conflicting_prefix("openrouter/anthropic/claude", Some("openai"))]
fn everything_else_is_rejected(#[case] model: &str, #[case] custom_llm_provider: Option<&str>) {
assert!(matches!(
resolve_provider(model, custom_llm_provider),
resolve_provider(model, custom_llm_provider, None, &LitellmParams::default()),
Err(Error::InvalidProvider(_) | Error::Unsupported(_))
));
}

View file

@ -6,6 +6,7 @@ use litellm_llms::base_llm::{
auth::ValidatedEnvironment, responses::transformation::BaseResponsesApiConfig,
};
use litellm_llms_types::formats::responses::ResponsesApiResponse;
use litellm_router_types::LitellmParams;
use serde_json::{Map, Value};
use super::Error;
@ -18,6 +19,8 @@ pub struct ResponsesCall {
pub api_base: Option<String>,
pub custom_llm_provider: Option<String>,
pub api_version: Option<String>,
/// The deployment's provider settings, such as AWS credentials and region
pub litellm_params: LitellmParams,
pub extra_headers: Option<Map<String, Value>>,
pub timeout: Option<Duration>,
}
@ -29,7 +32,7 @@ pub struct ResponsesStreamHead {
pub type ResponsesOutput = CallOutput<ResponsesApiResponse, ResponsesStreamHead, Bytes, Error>;
pub(super) struct ProviderResponsesRequest {
pub config: &'static dyn BaseResponsesApiConfig,
pub config: Box<dyn BaseResponsesApiConfig + Send>,
pub environment: ValidatedEnvironment,
pub url: String,
pub body: Value,

View file

@ -285,6 +285,7 @@ async fn responses_cache_identity_follows_resolved_configuration_and_request_cal
api_base: None,
custom_llm_provider: None,
api_version: None,
litellm_params: Default::default(),
extra_headers: None,
timeout: None,
},

View file

@ -34,6 +34,7 @@ fn call(stream: bool) -> ResponsesCall {
api_base: None,
custom_llm_provider: None,
api_version: None,
litellm_params: Default::default(),
extra_headers: None,
timeout: Some(Duration::from_secs(60)),
}

View file

@ -23,6 +23,7 @@ fn call() -> ResponsesCall {
api_base: None,
custom_llm_provider: None,
api_version: None,
litellm_params: Default::default(),
extra_headers: None,
timeout: None,
}

View file

@ -28,6 +28,7 @@ fn call() -> ResponsesCall {
api_base: None,
custom_llm_provider: None,
api_version: None,
litellm_params: Default::default(),
extra_headers: None,
timeout: None,
}

View file

@ -58,7 +58,7 @@
- `AnthropicConfig`, `AmazonConverseConfig` and the non-OCR base traits are partial. Preserve their acceptance gates, passthrough and host fallback contracts
- `OpenAiResponsesApiConfig` implements WebSocket transformations and direct HTTP Responses, without Python's model-specific param rewriting or Responses-to-Chat emulation
- OpenAI-compatible Responses hosts (`openrouter`, `azure`, `xai`, `hosted_vllm`, `fireworks_ai`, `databricks`) reuse the OpenAI request and response transforms as free functions and own only their credentials, URL and host-specific item rules
- OpenAI-compatible Responses hosts (`openrouter`, `azure`, `xai`, `hosted_vllm`, `fireworks_ai`, `databricks`, `bedrock_mantle`) reuse the OpenAI request and response transforms as free functions and own only their credentials, URL and host-specific item rules
- Chat `supported_openai_param_mappings` lists OpenAI/provider name pairs, unlike Python's name-only list. Audio `map_transcription_params` is a Rust filtering helper
- Auth resolution is split between configs and route preparation in `inference-<format>`

View file

@ -0,0 +1,72 @@
//! Tools a client sends as `additional_tools` input items, matching
//! `litellm/responses/additional_tools.py`
use serde_json::Value;
pub struct HoistedAdditionalTools {
pub input: Value,
pub hoisted: Vec<Value>,
}
/// The input without its `additional_tools` items, and the tools those items carried. An item
/// whose `tools` is not a list of objects carries none
pub fn hoist_additional_tools(input: Value) -> HoistedAdditionalTools {
let Value::Array(items) = input else {
return HoistedAdditionalTools {
input,
hoisted: Vec::new(),
};
};
let (additional, remaining): (Vec<Value>, Vec<Value>) = items
.into_iter()
.partition(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools"));
HoistedAdditionalTools {
input: Value::Array(remaining),
hoisted: additional.into_iter().flat_map(item_tools).collect(),
}
}
fn item_tools(item: Value) -> Vec<Value> {
match item.get("tools") {
None => Vec::new(),
Some(Value::Array(tools)) if tools.iter().all(Value::is_object) => tools.clone(),
Some(_) => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use serde_json::json;
use super::*;
#[rstest]
#[case::tools_are_hoisted_in_order(
json!([
{"type": "additional_tools", "tools": [{"type": "function", "name": "a"}]},
{"role": "user", "content": "hi"},
{"type": "additional_tools", "tools": [{"type": "mcp", "server_label": "b"}]},
]),
json!([{"role": "user", "content": "hi"}]),
vec![json!({"type": "function", "name": "a"}), json!({"type": "mcp", "server_label": "b"})]
)]
#[case::malformed_tools_carry_nothing_but_the_item_still_leaves(
json!([
{"type": "additional_tools", "tools": [{"type": "function"}, "bad"]},
{"type": "additional_tools", "tools": null},
{"type": "additional_tools"},
]),
json!([]),
vec![]
)]
#[case::string_input(json!("hi"), json!("hi"), vec![])]
fn additional_tools_items_are_hoisted(
#[case] input: Value,
#[case] remaining: Value,
#[case] hoisted: Vec<Value>,
) {
let result = hoist_additional_tools(input);
assert_eq!((result.input, result.hoisted), (remaining, hoisted));
}
}

View file

@ -0,0 +1,152 @@
//! Codex CLI wire-format quirks shared by the Responses providers that need them, matching
//! `litellm/llms/base_llm/responses/codex_compat.py`
use litellm_python_compat::{
json::{dumps, from_json},
repr::to_str,
truthy::truthy,
};
use serde_json::{Map, Value, json};
/// Codex history item types that api.openai.com accepts and other Responses backends reject,
/// rewritten to the spelling those backends take. An item that carries nothing to rewrite is
/// dropped
pub fn normalize_codex_input_items(input: Value) -> Value {
match input {
Value::Array(items) => Value::Array(items.into_iter().filter_map(normalized).collect()),
other => other,
}
}
/// The tools whose `type` the backend accepts; a tool that is not an object passes through
pub fn drop_unsupported_tools(tools: Vec<Value>, supported: &[&str]) -> Vec<Value> {
tools
.into_iter()
.filter(|tool| match tool {
Value::Object(fields) => fields
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| supported.contains(&kind)),
_ => true,
})
.collect()
}
fn normalized(item: Value) -> Option<Value> {
let Value::Object(fields) = &item else {
return Some(item);
};
match fields.get("type").and_then(Value::as_str) {
Some("agent_message") => agent_message(fields),
Some("context_compaction") => context_compaction(fields),
Some("local_shell_call") => local_shell_call(fields),
_ => Some(item),
}
}
fn agent_message(fields: &Map<String, Value>) -> Option<Value> {
let text: String = fields
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.map(|block| {
["text", "encrypted_content"]
.iter()
.filter_map(|name| block.get(*name))
.map(|value| from_json(value.clone()))
.find(truthy)
.map(|value| to_str(&value))
.unwrap_or_default()
})
.collect();
(!text.is_empty()).then(|| {
json!({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]})
})
}
fn context_compaction(fields: &Map<String, Value>) -> Option<Value> {
fields
.get("encrypted_content")
.and_then(Value::as_str)
.filter(|content| !content.is_empty())
.map(|content| json!({"type": "compaction", "encrypted_content": content}))
}
fn local_shell_call(fields: &Map<String, Value>) -> Option<Value> {
let call_id = fields
.get("call_id")
.and_then(Value::as_str)
.filter(|call_id| !call_id.is_empty())?;
let arguments = match fields.get("action") {
Some(action @ Value::Object(_)) => dumps(&from_json(action.clone())).ok()?,
_ => "{}".to_owned(),
};
Some(json!({
"type": "function_call",
"call_id": call_id,
"name": "local_shell",
"arguments": arguments,
}))
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::agent_message_joins_text_and_plaintext_args(
json!({"type": "agent_message", "content": [{"text": "a"}, {"encrypted_content": "b"}, {"text": "", "encrypted_content": "c"}, "skipped"]}),
Some(json!({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "abc"}]}))
)]
#[case::agent_message_renders_non_string_text_like_python(
json!({"type": "agent_message", "content": [{"text": 1.0}, {"text": true}]}),
Some(json!({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "1.0True"}]}))
)]
#[case::empty_agent_message_is_dropped(json!({"type": "agent_message", "content": [{"text": ""}]}), None)]
#[case::context_compaction(
json!({"type": "context_compaction", "encrypted_content": "enc"}),
Some(json!({"type": "compaction", "encrypted_content": "enc"}))
)]
#[case::empty_context_compaction_is_dropped(json!({"type": "context_compaction", "encrypted_content": ""}), None)]
#[case::local_shell_call_dumps_its_action_like_python(
json!({"type": "local_shell_call", "call_id": "c1", "action": {"command": ["ls", "é"], "timeout": 1}}),
Some(json!({"type": "function_call", "call_id": "c1", "name": "local_shell", "arguments": "{\"command\": [\"ls\", \"\\u00e9\"], \"timeout\": 1}"}))
)]
#[case::local_shell_call_without_action(
json!({"type": "local_shell_call", "call_id": "c1", "action": "ls"}),
Some(json!({"type": "function_call", "call_id": "c1", "name": "local_shell", "arguments": "{}"}))
)]
#[case::local_shell_call_without_call_id_is_dropped(json!({"type": "local_shell_call", "call_id": ""}), None)]
#[case::other_items_pass_through(json!({"type": "message", "role": "user", "content": "hi"}), Some(json!({"type": "message", "role": "user", "content": "hi"})))]
fn codex_items_take_the_backend_spelling(#[case] item: Value, #[case] expected: Option<Value>) {
assert_eq!(
normalize_codex_input_items(json!([item])),
Value::Array(expected.into_iter().collect())
);
}
#[test]
fn string_input_is_untouched() {
assert_eq!(normalize_codex_input_items(json!("hi")), json!("hi"));
}
#[test]
fn unsupported_tool_types_are_dropped() {
assert_eq!(
drop_unsupported_tools(
vec![
json!({"type": "function", "name": "f"}),
json!({"type": "web_search"}),
json!({"name": "untyped"}),
json!("opaque"),
],
&["function"],
),
vec![json!({"type": "function", "name": "f"}), json!("opaque")]
);
}
}

View file

@ -1,2 +1,4 @@
pub mod additional_tools;
pub mod codex_compat;
pub mod streaming;
pub mod transformation;

View file

@ -1 +1,2 @@
pub mod messages;
pub mod responses;

View file

@ -0,0 +1,12 @@
# rules
- Send the OpenAI Responses payload to `/openai/v1/responses` when the host passes `use_openai_responses_path` from the model cost map, else `/v1/responses`
- A standard `bedrock-mantle.<region>.api.aws` base is re-pinned to the resolved region, and any other base keeps its path
- Authenticate with a bearer from `api_key`, `BEDROCK_MANTLE_API_KEY` or `AWS_BEARER_TOKEN_BEDROCK`, otherwise sign with AWS SigV4 for service `bedrock`
- Keep only the tool types Mantle accepts, hoist `additional_tools` input items into `tools`, and rewrite Codex history items
- Models that serve only Chat Completions, and `service_tier` or `reasoning.summary` values Mantle rejects, stay on Python
- No native WebSocket mode
# references
- Python counterpart `litellm/llms/bedrock_mantle/responses/transformation.py`

View file

@ -0,0 +1 @@
pub mod transformation;

View file

@ -0,0 +1,504 @@
//! Bedrock Mantle Responses, matching `litellm/llms/bedrock_mantle/responses/transformation.py`
use std::sync::LazyLock;
use litellm_auth::{AwsParams, CredentialPlacement, SecretValue};
use litellm_auth_aws::{AwsCredentialSource, is_bedrock_region};
use litellm_http::request::{with_header, without_headers};
use litellm_llms_types::formats::responses::ResponsesApiResponse;
use litellm_python_compat::truthy::json_truthy;
use litellm_router_types::LitellmParams;
use serde_json::{Map, Value};
use crate::{
Error,
base_llm::{
auth::{AuthScheme, ValidatedEnvironment},
responses::{
additional_tools::hoist_additional_tools,
codex_compat::{drop_unsupported_tools, normalize_codex_input_items},
transformation::BaseResponsesApiConfig,
},
},
bedrock::common_utils::validated_region,
openai::responses::{
request::ResponsesHost,
transformation::{responses_request, responses_response},
},
};
const KEY_ENVS: [&str; 2] = ["BEDROCK_MANTLE_API_KEY", "AWS_BEARER_TOKEN_BEDROCK"];
const API_BASE_ENV: &str = "BEDROCK_MANTLE_API_BASE";
const REGION_ENVS: [&str; 3] = ["BEDROCK_MANTLE_REGION", "AWS_REGION_NAME", "AWS_REGION"];
const DEFAULT_REGION: &str = "us-east-1";
const BASE_SUFFIXES: [&str; 5] = [
"/openai/v1/responses",
"/v1/responses",
"/responses",
"/openai/v1",
"/v1",
];
/// Per Bedrock Mantle Responses API validation errors
const SUPPORTED_TOOL_TYPES: [&str; 6] = [
"function",
"mcp",
"custom",
"namespace",
"tool_search",
"web_search",
];
/// The price map's `use_openai_responses_path`, which the host reads for the model
const OPENAI_PATH_PARAM: &str = "use_openai_responses_path";
const PROJECT_PARAM: &str = "aws_bedrock_project_id";
/// One call's Mantle connection, since the model picks the Responses path
pub struct BedrockMantleResponsesApiConfig {
api_base: Option<String>,
model_region: Option<String>,
aws: AwsParams,
project_id: Option<String>,
openai_path: bool,
}
impl BedrockMantleResponsesApiConfig {
pub fn new(api_base: Option<&str>, model_region: Option<&str>, params: &LitellmParams) -> Self {
Self {
api_base: api_base.map(str::to_owned),
model_region: model_region.map(str::to_owned),
aws: params.aws.clone(),
project_id: params
.extra
.get(PROJECT_PARAM)
.and_then(Value::as_str)
.filter(|project| !project.is_empty())
.map(str::to_owned),
openai_path: params.extra.get(OPENAI_PATH_PARAM) == Some(&Value::Bool(true)),
}
}
/// Python's `resolve_mantle_region`; the model's region prefix stands in for the default
/// base `get_llm_provider` builds from it
fn region(
&self,
api_base: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<String, Error> {
if let Some(region) = non_empty(self.aws.aws_region_name.as_deref()) {
return validated_region(region.to_owned());
}
let routed = match configured_base(api_base, lookup) {
Some(base) => mantle_host_region(base.trim_end_matches('/')).map(str::to_owned),
None => self.model_region.clone(),
};
validated_region(
routed
.or_else(|| {
REGION_ENVS
.iter()
.find_map(|name| lookup(name).filter(|value| !value.is_empty()))
})
.unwrap_or_else(|| DEFAULT_REGION.into()),
)
}
}
/// A leading Bedrock region in a Mantle model id routes the call and is not sent
pub fn split_region_prefix(model: &str) -> (Option<&str>, &str) {
match model.split_once('/') {
Some((region, rest)) if is_bedrock_region(region) => (Some(region), rest),
_ => (None, model),
}
}
impl BaseResponsesApiConfig for BedrockMantleResponsesApiConfig {
fn secret_names(
&self,
_api_key: Option<&str>,
_api_base: Option<&str>,
) -> &'static [&'static str] {
static NAMES: LazyLock<Box<[&'static str]>> = LazyLock::new(|| {
KEY_ENVS
.into_iter()
.chain([API_BASE_ENV])
.chain(REGION_ENVS)
.chain(
AwsParams::secret_names()
.iter()
.copied()
.filter(|name| !REGION_ENVS.contains(name)),
)
.collect()
});
&NAMES
}
fn validate_environment(
&self,
headers: Vec<(String, String)>,
api_key: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<ValidatedEnvironment, Error> {
let headers = with_header(headers, "content-type", "application/json".into());
let headers = match &self.project_id {
Some(project) => with_header(headers, "OpenAI-Project", project.clone()),
None => headers,
};
let bearer = non_empty(api_key).map(str::to_owned).or_else(|| {
KEY_ENVS
.iter()
.find_map(|name| lookup(name).filter(|value| !value.is_empty()))
});
Ok(match bearer {
Some(token) => ValidatedEnvironment {
headers,
auth: AuthScheme::Credential {
placement: CredentialPlacement::Bearer,
secret: SecretValue::new(token),
},
},
None => ValidatedEnvironment {
headers: without_headers(headers, &["authorization"]),
auth: AuthScheme::AwsSigV4 {
region: self.region(self.api_base.as_deref(), lookup)?,
service: "bedrock",
credentials: Box::new(AwsCredentialSource::from_params(&self.aws, lookup)),
},
},
})
}
fn get_complete_url(
&self,
api_base: Option<&str>,
_api_version: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<String, Error> {
let region = self.region(api_base, lookup)?;
let default = format!("https://bedrock-mantle.{region}.api.aws");
let configured = configured_base(api_base, lookup).unwrap_or_else(|| default.clone());
let trimmed = configured.trim_end_matches('/');
let stripped = BASE_SUFFIXES
.iter()
.find_map(|suffix| trimmed.strip_suffix(suffix))
.unwrap_or(trimmed);
let base = match mantle_host_region(stripped) {
Some(_) => default.as_str(),
None => stripped,
};
let path = match self.openai_path {
true => "/openai/v1/responses",
false => "/v1/responses",
};
Ok(format!("{base}{path}"))
}
fn transform_responses_api_request(
&self,
model: &str,
input: Value,
params: Map<String, Value>,
) -> Result<Value, Error> {
let params = with_supported_tools(params);
let hoisted = hoist_additional_tools(input);
let params = match hoisted.hoisted.is_empty() {
true => params,
false => with_hoisted_tools(params, hoisted.hoisted),
};
responses_request(
model,
normalize_codex_input_items(hoisted.input),
params,
ResponsesHost::OpenAi,
)
}
fn transform_response_api_response(&self, body: Value) -> Result<ResponsesApiResponse, Error> {
responses_response(body)
}
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.is_empty())
}
fn configured_base(
api_base: Option<&str>,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
non_empty(api_base)
.map(str::to_owned)
.or_else(|| lookup(API_BASE_ENV).filter(|base| !base.is_empty()))
}
/// Python's `MANTLE_HOST_RE`: `^https?://bedrock-mantle\.([^/.]+)\.api\.aws(?=/|$)`, ignoring case
fn mantle_host_region(base: &str) -> Option<&str> {
let lowered = base.to_ascii_lowercase();
let scheme = ["https://", "http://"]
.into_iter()
.find(|scheme| lowered.starts_with(scheme))?;
let host = &base[scheme.len()..];
let region_start = "bedrock-mantle.".len();
if !host
.get(..region_start)?
.eq_ignore_ascii_case("bedrock-mantle.")
{
return None;
}
let after = &host[region_start..];
let region_end = after.find(['.', '/']).filter(|end| *end > 0)?;
let rest = &after[region_end..];
let suffix = ".api.aws";
let matched = rest.get(..suffix.len())?.eq_ignore_ascii_case(suffix)
&& matches!(rest.as_bytes().get(suffix.len()), None | Some(b'/'));
matched.then(|| &after[..region_end])
}
/// Python's `map_openai_params` tool filter: a single tool is listed, and no supported tool
/// leaves no `tools`
fn with_supported_tools(mut params: Map<String, Value>) -> Map<String, Value> {
if !params.get("tools").is_some_and(json_truthy) {
return params;
}
let listed = match params.remove("tools") {
Some(Value::Array(tools)) => tools,
tool => tool.into_iter().collect(),
};
with_tools(
params,
drop_unsupported_tools(listed, &SUPPORTED_TOOL_TYPES),
)
}
fn with_hoisted_tools(mut params: Map<String, Value>, hoisted: Vec<Value>) -> Map<String, Value> {
let existing = match params.remove("tools") {
Some(Value::Array(tools)) => tools,
_ => Vec::new(),
};
let combined = existing.into_iter().chain(hoisted).collect();
with_tools(
params,
drop_unsupported_tools(combined, &SUPPORTED_TOOL_TYPES),
)
}
fn with_tools(mut params: Map<String, Value>, tools: Vec<Value>) -> Map<String, Value> {
if !tools.is_empty() {
params.insert("tools".into(), Value::Array(tools));
}
params
}
#[cfg(test)]
mod tests {
use litellm_router_types::LitellmParams;
use rstest::rstest;
use serde_json::json;
use super::*;
fn env(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |name| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_owned())
}
}
fn params(mut fields: Value) -> LitellmParams {
fields["model"] = json!("m");
serde_json::from_value(fields).unwrap()
}
fn config(
api_base: Option<&str>,
model_region: Option<&str>,
fields: Value,
) -> BedrockMantleResponsesApiConfig {
BedrockMantleResponsesApiConfig::new(api_base, model_region, &params(fields))
}
#[rstest]
#[case::default_region(None, None, json!({}), &[], "https://bedrock-mantle.us-east-1.api.aws/v1/responses")]
#[case::openai_path(None, None, json!({"use_openai_responses_path": true}), &[], "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses")]
#[case::model_prefix_region(None, Some("us-west-2"), json!({}), &[], "https://bedrock-mantle.us-west-2.api.aws/v1/responses")]
#[case::region_env(None, None, json!({}), &[("AWS_REGION", "eu-west-1")], "https://bedrock-mantle.eu-west-1.api.aws/v1/responses")]
#[case::aws_region_name_repins_a_mantle_host(
Some("https://bedrock-mantle.us-east-1.api.aws/openai/v1"),
None,
json!({"aws_region_name": "us-west-2", "use_openai_responses_path": true}),
&[],
"https://bedrock-mantle.us-west-2.api.aws/openai/v1/responses"
)]
#[case::mantle_host_region_beats_the_env(
Some("https://Bedrock-Mantle.eu-central-1.api.aws/v1/responses/"),
None,
json!({}),
&[("BEDROCK_MANTLE_REGION", "us-west-2")],
"https://bedrock-mantle.eu-central-1.api.aws/v1/responses"
)]
#[case::custom_proxy_keeps_its_path(
Some("https://proxy.test/tenant/v1"),
Some("us-west-2"),
json!({}),
&[],
"https://proxy.test/tenant/v1/responses"
)]
#[case::base_secret(None, None, json!({}), &[("BEDROCK_MANTLE_API_BASE", "https://proxy.test")], "https://proxy.test/v1/responses")]
fn url_matches_python(
#[case] api_base: Option<&str>,
#[case] model_region: Option<&str>,
#[case] fields: Value,
#[case] secrets: &'static [(&'static str, &'static str)],
#[case] expected: &str,
) {
assert_eq!(
config(api_base, model_region, fields)
.get_complete_url(api_base, None, &env(secrets))
.unwrap(),
expected
);
}
#[rstest]
#[case::https("https://bedrock-mantle.us-east-1.api.aws", Some("us-east-1"))]
#[case::path("http://bedrock-mantle.us-east-1.api.aws/tenant", Some("us-east-1"))]
#[case::port("https://bedrock-mantle.us-east-1.api.aws:443", None)]
#[case::other_domain("https://bedrock-mantle.us-east-1.api.aws.test", None)]
#[case::empty_region("https://bedrock-mantle..api.aws", None)]
#[case::proxy("https://proxy.test", None)]
fn mantle_hosts_match_pythons_pattern(#[case] base: &str, #[case] expected: Option<&str>) {
assert_eq!(mantle_host_region(base), expected);
}
#[rstest]
#[case::regional("us-west-2/openai.gpt-5.5", Some("us-west-2"), "openai.gpt-5.5")]
#[case::gov(
"us-gov-west-1/openai.gpt-5.4",
Some("us-gov-west-1"),
"openai.gpt-5.4"
)]
#[case::bare("openai.gpt-5.5", None, "openai.gpt-5.5")]
fn region_prefixes_route_and_are_not_sent(
#[case] model: &str,
#[case] region: Option<&str>,
#[case] wire: &str,
) {
assert_eq!(split_region_prefix(model), (region, wire));
}
#[rstest]
#[case::explicit(Some("explicit"), &[("BEDROCK_MANTLE_API_KEY", "env")], "explicit")]
#[case::mantle_secret(None, &[("BEDROCK_MANTLE_API_KEY", "mantle"), ("AWS_BEARER_TOKEN_BEDROCK", "aws")], "mantle")]
#[case::bedrock_secret(Some(""), &[("AWS_BEARER_TOKEN_BEDROCK", "aws")], "aws")]
fn bearer_tokens_skip_sigv4(
#[case] api_key: Option<&str>,
#[case] secrets: &'static [(&'static str, &'static str)],
#[case] expected: &str,
) {
let environment = config(None, None, json!({}))
.validate_environment(vec![], api_key, &env(secrets))
.unwrap();
let AuthScheme::Credential { placement, secret } = environment.auth else {
panic!("expected a bearer credential");
};
assert_eq!(
(placement, secret.expose()),
(CredentialPlacement::Bearer, expected)
);
}
#[test]
fn without_a_bearer_the_call_is_sigv4_signed_for_the_url_region() {
let environment = config(
Some("https://bedrock-mantle.eu-west-1.api.aws/openai/v1"),
Some("us-west-2"),
json!({"aws_bedrock_project_id": "proj_1"}),
)
.validate_environment(
vec![("Authorization".into(), "Bearer caller".into())],
None,
&env(&[]),
)
.unwrap();
assert!(matches!(
environment.auth,
AuthScheme::AwsSigV4 { ref region, service: "bedrock", .. } if region == "eu-west-1"
));
assert_eq!(
environment.headers,
vec![
("content-type".to_owned(), "application/json".to_owned()),
("openai-project".to_owned(), "proj_1".to_owned()),
]
);
}
#[test]
fn an_invalid_region_is_a_request_error() {
assert!(matches!(
config(None, None, json!({"aws_region_name": "US EAST"})).get_complete_url(
None,
None,
&env(&[])
),
Err(Error::InvalidRequest(_))
));
}
#[rstest]
#[case::unsupported_dropped(
json!([{"type": "function", "name": "f"}, {"type": "file_search"}]),
json!("hi"),
Some(json!([{"type": "function", "name": "f"}]))
)]
#[case::single_tool_listed(json!({"type": "web_search"}), json!("hi"), Some(json!([{"type": "web_search"}])))]
#[case::none_supported(json!([{"type": "image_generation"}]), json!("hi"), None)]
#[case::empty_list_kept(json!([]), json!("hi"), Some(json!([])))]
#[case::hoisted_after_existing(
json!([{"type": "function", "name": "f"}]),
json!([{"type": "additional_tools", "tools": [{"type": "mcp", "server_label": "s"}, {"type": "code_interpreter"}]}, {"role": "user", "content": "hi"}]),
Some(json!([{"type": "function", "name": "f"}, {"type": "mcp", "server_label": "s"}]))
)]
#[case::hoisted_into_empty_tools(
json!([]),
json!([{"type": "additional_tools", "tools": [{"type": "code_interpreter"}]}, {"role": "user", "content": "hi"}]),
None
)]
fn tools_are_filtered_to_what_mantle_accepts(
#[case] tools: Value,
#[case] input: Value,
#[case] expected: Option<Value>,
) {
let body = config(None, None, json!({}))
.transform_responses_api_request(
"openai.gpt-5.5",
input,
Map::from_iter([("tools".into(), tools)]),
)
.unwrap();
assert_eq!(body.get("tools").cloned(), expected);
}
#[test]
fn codex_history_is_rewritten_after_hoisting() {
let body = config(None, None, json!({}))
.transform_responses_api_request(
"openai.gpt-5.5",
json!([
{"type": "additional_tools", "tools": [{"type": "function", "name": "f"}]},
{"type": "context_compaction", "encrypted_content": "enc"},
{"role": "user", "content": "hi"},
]),
Map::new(),
)
.unwrap();
assert_eq!(
(&body["input"], &body["tools"]),
(
&json!([{"type": "compaction", "encrypted_content": "enc"}, {"role": "user", "content": "hi"}]),
&json!([{"type": "function", "name": "f"}])
)
);
}
}

View file

@ -2,6 +2,7 @@
use litellm_auth::{CredentialPlacement, SecretValue};
use litellm_llms_types::formats::responses::ResponsesApiResponse;
use litellm_python_compat::truthy::json_truthy;
use serde_json::{Map, Value, json};
use crate::{
@ -89,7 +90,7 @@ impl BaseResponsesApiConfig for XaiResponsesApiConfig {
mut params: Map<String, Value>,
) -> Result<Value, Error> {
params.remove("metadata");
if let Some(tools) = params.get_mut("tools").filter(|tools| python_truthy(tools)) {
if let Some(tools) = params.get_mut("tools").filter(|tools| json_truthy(tools)) {
let listed = match tools.take() {
Value::Array(tools) => tools,
tool => vec![tool],
@ -122,17 +123,6 @@ fn resolved(
.or_else(|| lookup(name).filter(|value| !value.is_empty()))
}
fn python_truthy(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(value) => *value,
Value::Number(number) => number.as_f64() != Some(0.0),
Value::String(value) => !value.is_empty(),
Value::Array(values) => !values.is_empty(),
Value::Object(values) => !values.is_empty(),
}
}
/// Python's `xai_reported_cost_in_usd`: only a non-negative integer tick count is a cost
fn reported_cost(ticks: Option<&Value>) -> Option<f64> {
ticks?

View file

@ -1,6 +1,7 @@
use std::convert::Infallible;
use crate::routes::inference::InferenceHost;
use crate::{marshal::project_optional_fields, routes::inference::InferenceHost};
use litellm_auth::AwsParams;
use litellm_core_utils::get_llm_provider_logic::get_custom_llm_provider;
use litellm_host_python::{InvokeError, PythonBinding, PythonHostCalls, PythonOwned, from_py};
use litellm_inference_responses::{
@ -9,7 +10,9 @@ use litellm_inference_responses::{
route::Responses,
types::ResponsesCall,
};
use litellm_router_types::LitellmParams;
use pyo3::{
exceptions::PyValueError,
gc::{PyTraverseError, PyVisit},
prelude::*,
types::PyDict,
@ -53,6 +56,15 @@ pub(super) fn project(
.or(raw("base_url")?),
))?
.extract()?;
let litellm_params = litellm_params(
py,
&call.options.model,
call.options.custom_llm_provider.as_deref(),
project_optional_fields(
AwsParams::fields().chain(["aws_bedrock_project_id"]),
|name| host.argument(py, arguments, name),
)?,
)?;
let optional_params = call
.params
.into_iter()
@ -89,11 +101,36 @@ pub(super) fn project(
.argument(py, arguments, "api_version")?
.map(|value| value.extract())
.transpose()?,
litellm_params,
extra_headers: call.options.extra_headers,
timeout: call.options.timeout,
})
}
/// The caller's AWS settings plus what Python reads from the model cost map for the provider
fn litellm_params(
py: Python<'_>,
model: &str,
custom_llm_provider: Option<&str>,
fields: serde_json::Map<String, serde_json::Value>,
) -> PyResult<LitellmParams> {
let provider_params: Vec<(String, serde_json::Value)> = from_py(
&py.import(super::ROUTE_HOST_MODULE)?
.getattr("provider_params")?
.call1((model, custom_llm_provider))?,
)?;
let fields = [(
"model".to_owned(),
serde_json::Value::String(model.to_owned()),
)]
.into_iter()
.chain(fields)
.chain(provider_params)
.collect();
serde_json::from_value(serde_json::Value::Object(fields))
.map_err(|error| PyValueError::new_err(format!("invalid litellm params: {error}")))
}
/// The provider and deployment Python encodes into the ids it returns: the resolved provider and
/// `litellm_metadata.model_info.id`
fn id_scope(

View file

@ -47,7 +47,9 @@ fn run_responses(py: Python<'_>, call: NativeCall<'_>, asynchronous: bool) -> Py
None => !model.contains('/'),
Some(resolved) => match resolved.custom_llm_provider {
"openai" => !resolved.model.contains('/'),
"azure" | "xai" | "hosted_vllm" | "fireworks_ai" | "databricks" => true,
"azure" | "xai" | "hosted_vllm" | "fireworks_ai" | "databricks" | "bedrock_mantle" => {
true
}
_ => false,
},
};

View file

@ -16,3 +16,15 @@ pub fn truthy(value: &Value) -> bool {
Value::Dict(entries) => !entries.is_empty(),
}
}
/// [`truthy`] for the value `json.loads` returns, without converting it
pub fn json_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(value) => *value,
serde_json::Value::Number(number) => number.as_f64() != Some(0.0),
serde_json::Value::String(value) => !value.is_empty(),
serde_json::Value::Array(values) => !values.is_empty(),
serde_json::Value::Object(values) => !values.is_empty(),
}
}

View file

@ -8,6 +8,7 @@ from typing import Final, cast # noqa: TID251 # narrows caller json the checke
import litellm
from litellm import get_llm_provider
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.bedrock_mantle.responses.transformation import BedrockMantleResponsesAPIConfig
from litellm.llms.databricks.responses.transformation import DatabricksResponsesAPIConfig
from litellm.llms.fireworks_ai.common_utils import get_fireworks_session_id
from litellm.llms.fireworks_ai.responses.transformation import FireworksAIResponsesAPIConfig
@ -19,9 +20,25 @@ from litellm.rust_bridge.public_call import inference_decline_reason, known_para
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams, ResponsesAPIResponse
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
PARAMETERS: Final = tuple(ResponsesAPIOptionalRequestParams.__annotations__)
_NATIVE_PARAMETERS: Final = (*PARAMETERS, "api_version")
_MANTLE_PARAMETERS: Final = (
*_NATIVE_PARAMETERS,
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"aws_region_name",
"aws_session_name",
"aws_profile_name",
"aws_role_name",
"aws_web_identity_token",
"aws_sts_endpoint",
"aws_external_id",
"aws_bedrock_project_id",
)
_CONFIGS: Final[Mapping[str, OpenAIResponsesAPIConfig]] = MappingProxyType(
{
"openai": OpenAIResponsesAPIConfig(),
@ -40,7 +57,7 @@ def body_params(request: Mapping[str, object]) -> Mapping[str, object]:
def connection_defaults(provider: str) -> tuple[str | None, str | None]:
if provider == "databricks":
if provider in ("databricks", "bedrock_mantle"):
return None, None
provider_key: Final = {"openai": litellm.openai_key, "azure": litellm.azure_key}.get(provider)
return litellm.api_key or provider_key, litellm.api_base
@ -56,6 +73,21 @@ def provider_connection(
return provider_key, provider_base
def provider_params(model: str, custom_llm_provider: str | None) -> tuple[tuple[str, object], ...]:
"""The provider settings Python reads from the model cost map, as litellm params"""
resolved, provider = _resolved_model({"model": model, "custom_llm_provider": custom_llm_provider})
if provider != "bedrock_mantle":
return ()
config: Final = _mantle_config(resolved)
return () if config is None else (("use_openai_responses_path", config.use_openai_path),)
def _mantle_config(model: str) -> BedrockMantleResponsesAPIConfig | None:
"""The config Python's dispatch picks from the model cost map, or None for chat-only models"""
config: Final = ProviderConfigManager.get_provider_responses_api_config(LlmProviders.BEDROCK_MANTLE, model)
return config if isinstance(config, BedrockMantleResponsesAPIConfig) else None
def response(value: Mapping[str, object]) -> ResponsesAPIResponse:
return ResponsesAPIResponse.model_validate(value)
@ -84,16 +116,22 @@ def decline_reason(request: Mapping[str, object]) -> str | None:
model, provider = _resolved_model(request)
except litellm.exceptions.BadRequestError:
return "native Responses could not resolve the provider"
config: Final = _CONFIGS.get(provider)
config: Final = _responses_config(provider, model)
if config is None:
return "native HTTP responses provider"
return (
inference_decline_reason(_NATIVE_PARAMETERS, request)
inference_decline_reason(_MANTLE_PARAMETERS if provider == "bedrock_mantle" else _NATIVE_PARAMETERS, request)
or _provider_decline_reason(provider, model, request)
or _model_rules_decline_reason(config, model, request)
)
def _responses_config(provider: str, model: str) -> OpenAIResponsesAPIConfig | None:
if provider != "bedrock_mantle":
return _CONFIGS.get(provider)
return _mantle_config(model)
def _provider_decline_reason(provider: str, model: str, request: Mapping[str, object]) -> str | None:
if provider == "azure":
return _azure_decline_reason(model, request)

View file

@ -5,6 +5,7 @@ recorded OpenAI response, then compares what reached the provider, what the call
callback logged. The native leg fails instead of silently falling back to Python
"""
import re
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
@ -33,6 +34,8 @@ _COMPARED_HEADERS: Final = (
"openai-project",
"user-agent",
)
_SIGV4_TIME: Final = re.compile(r"/\d{8}/")
_SIGV4_SIGNATURE: Final = re.compile(r"Signature=[0-9a-f]{64}")
_COMPARED_LOG_FIELDS: Final = (
"call_type",
"custom_llm_provider",
@ -175,6 +178,20 @@ FIREWORKS_INSTRUCTION_INPUT: Final[list[JsonValue]] = [
{"role": "developer", "content": "Now be verbose"},
]
HOSTED_VLLM_MODEL: Final = "hosted_vllm/meta-llama/Llama-3.1-8B-Instruct"
MANTLE_MODEL: Final = "bedrock_mantle/openai.gpt-5.5"
MANTLE_CODEX_INPUT: Final[list[JsonValue]] = [
{"type": "additional_tools", "tools": [{"type": "mcp", "server_label": "docs"}, {"type": "image_generation"}]},
{"type": "context_compaction", "encrypted_content": "gAAAAB0a1b2c3d"},
{"type": "agent_message", "content": [{"type": "input_text", "text": "Planning"}]},
{"type": "local_shell_call", "call_id": "call_ls", "action": {"type": "exec", "command": ["ls", "-la"]}},
{"type": "function_call_output", "call_id": "call_ls", "output": "README.md"},
{"role": "user", "content": "Continue"},
]
MANTLE_TOOLS: Final[list[JsonValue]] = [
dict(WEATHER_TOOL),
{"type": "web_search"},
{"type": "code_interpreter", "container": {"type": "auto"}},
]
XAI_RESPONSE: Final[Mapping[str, JsonValue]] = MappingProxyType(
{
@ -391,6 +408,31 @@ CASES: Final = (
"max_output_tokens": 4,
},
),
Case("bedrock_mantle_openai_path", {"model": MANTLE_MODEL, "input": "hi"}),
Case(
"bedrock_mantle_regional_v1_path",
{"model": "bedrock_mantle/us-west-2/openai.gpt-oss-120b", "input": "hi", "aws_bedrock_project_id": "proj_1"},
),
Case(
"bedrock_mantle_codex_history_and_tools",
{"model": MANTLE_MODEL, "input": MANTLE_CODEX_INPUT, "tools": MANTLE_TOOLS},
),
Case(
"bedrock_mantle_sigv4",
{
"model": MANTLE_MODEL,
"input": "hi",
"api_key": None,
"aws_region_name": "us-west-2",
"aws_access_key_id": "AKIDEXAMPLE",
"aws_secret_access_key": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
},
),
Case(
"bedrock_mantle_service_tier_stays_on_python",
{"model": MANTLE_MODEL, "input": "hi", "service_tier": "flex", "drop_params": True},
falls_back=True,
),
Case("azure_o_series_stays_on_python", {"model": "azure/o_series/my-o3", "input": "hi"}, falls_back=True),
Case(
"tool_schema_repair_stays_on_python",
@ -407,6 +449,11 @@ def _kwargs(case: Case, server: RecordingServer, recorder: RecordingLogger) -> d
return {"model": MODEL, "api_key": "sk-test", "api_base": server.base_url, "callbacks": [recorder], **case.kwargs}
def _comparable_header(value: str) -> str:
"""A SigV4 signature covers the signing time, so only its scope and signed headers compare"""
return _SIGV4_TIME.sub("/<date>/", _SIGV4_SIGNATURE.sub("Signature=<signature>", value))
def _response(result: object) -> Mapping[str, JsonValue]:
assert isinstance(result, ResponsesAPIResponse)
return _OBJECT.validate_python(result.model_dump(mode="json", exclude_none=True))
@ -442,7 +489,7 @@ async def _isolated_leg(case: Case, server: RecordingServer, probe: FallbackProb
request_path=sent.path,
request_body=_OBJECT.validate_python(sent.body),
request_headers=MappingProxyType(
{name: value for name, value in sent.headers.items() if name in _COMPARED_HEADERS}
{name: _comparable_header(value) for name, value in sent.headers.items() if name in _COMPARED_HEADERS}
),
response=_response(result),
logged=MappingProxyType({name: standard.get(name) for name in _COMPARED_LOG_FIELDS}),

View file

@ -10,6 +10,7 @@ from litellm.rust_bridge.host.routes.responses import (
decline_reason,
map_failure,
provider_connection,
provider_params,
response,
)
from litellm.types.llms.openai import ResponsesAPIResponse
@ -316,3 +317,81 @@ def test_databricks_connection_defaults_ignore_the_global_settings(monkeypatch:
monkeypatch.setattr(litellm, "api_key", "global")
monkeypatch.setattr(litellm, "api_base", "https://global.invalid")
assert connection_defaults("databricks") == (None, None)
_MANTLE_MODEL_COST: Final = {
"bedrock_mantle/openai.gpt-openai-path": {"mode": "responses", "use_openai_responses_path": True},
"bedrock_mantle/openai.gpt-v1-path": {"mode": "chat", "supported_endpoints": ["/v1/responses"]},
"bedrock_mantle/openai.gpt-chat-only": {"mode": "chat"},
}
@pytest.mark.parametrize(
("model", "expected"),
[
pytest.param("bedrock_mantle/openai.gpt-openai-path", (("use_openai_responses_path", True),), id="openai"),
pytest.param(
"bedrock_mantle/us-west-2/openai.gpt-v1-path", (("use_openai_responses_path", False),), id="regional_v1"
),
pytest.param("openai/gpt-openai-path", (), id="other_provider"),
],
)
def test_provider_params_carry_the_mantle_path_from_the_cost_map(
monkeypatch: pytest.MonkeyPatch, model: str, expected: tuple[tuple[str, object], ...]
) -> None:
monkeypatch.setattr(litellm, "model_cost", _MANTLE_MODEL_COST)
assert provider_params(model, None) == expected
@pytest.mark.parametrize(
("request_fields", "expected"),
[
pytest.param({}, None, id="responses_model"),
pytest.param({"model": "bedrock_mantle/openai.gpt-v1-path"}, None, id="supported_endpoint"),
pytest.param(
{"aws_region_name": "us-west-2", "aws_role_name": "arn:aws:iam::1:role/r", "aws_bedrock_project_id": "p"},
None,
id="aws_settings",
),
pytest.param(
{"aws_session_tags": {"team": "a"}},
"native inference does not implement aws_session_tags",
id="aws_session_tags",
),
pytest.param(
{"model": "bedrock_mantle/openai.gpt-chat-only"}, "native HTTP responses provider", id="chat_only_model"
),
pytest.param(
{"service_tier": "flex"},
"native Responses does not implement the model's sampling rules",
id="service_tier",
),
pytest.param(
{"reasoning": {"summary": "detailed"}},
"native Responses does not implement the model's sampling rules",
id="openai_path_reasoning_summary",
),
pytest.param(
{"model": "bedrock_mantle/openai.gpt-v1-path", "reasoning": {"summary": "detailed"}},
None,
id="v1_path_reasoning_summary",
),
],
)
def test_bedrock_mantle_requests_outside_the_native_config_stay_on_python(
monkeypatch: pytest.MonkeyPatch, request_fields: dict[str, object], expected: str | None
) -> None:
monkeypatch.setattr(litellm, "model_cost", _MANTLE_MODEL_COST)
request: Final = {"model": "bedrock_mantle/openai.gpt-openai-path", "input": "hi", **request_fields}
assert decline_reason(request) == expected
def test_aws_settings_stay_on_python_for_other_providers() -> None:
request: Final = {"model": "gpt-4.1", "input": "hi", "api_key": "sk-test", "aws_region_name": "us-west-2"}
assert decline_reason(request) == "native inference does not implement aws_region_name"
def test_bedrock_mantle_connection_defaults_ignore_the_global_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "api_key", "global")
monkeypatch.setattr(litellm, "api_base", "https://global.invalid")
assert connection_defaults("bedrock_mantle") == (None, None)