Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	tests/unit/litellm_core_utils/llm_response_utils/test_convert_dict_to_response.py
This commit is contained in:
mateo-berri 2026-10-29 09:15:04 -07:00
commit 3f1d7e2c31
121 changed files with 8818 additions and 249 deletions

View file

@ -7,6 +7,8 @@ import zipfile
from pathlib import Path
from typing import Final
COST_MAP: Final = Path(__file__).resolve().parents[2] / "model_prices_and_context_window.json"
CHILD_SCRIPT: Final = """
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
@ -34,6 +36,9 @@ else:
after = module.gil_stats()
if not isinstance(after.get("releases"), int):
raise AssertionError(f"native module unusable after panic: {after!r}")
if module.bundled_model_cost_map() != Path(sys.argv[2]).read_bytes():
raise AssertionError("the wheel embeds a different model_prices_and_context_window.json than this checkout")
"""
@ -55,7 +60,9 @@ def main() -> int:
native_path: Final = Path(temporary_directory) / Path(native_members[0].filename).name
native_path.write_bytes(archive.read(native_members[0]))
result: Final = subprocess.run((sys.executable, "-c", CHILD_SCRIPT, str(native_path)), check=False)
result: Final = subprocess.run(
(sys.executable, "-c", CHILD_SCRIPT, str(native_path), str(COST_MAP)), check=False
)
if result.returncode != 0:
sys.stderr.write(f"native wheel smoke test exited with status {result.returncode}\n")

View file

@ -46,3 +46,24 @@ jobs:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
uv run --no-project python ci_cd/cost_map_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF"
- name: Check whether the pull request changes the cost map
id: changed
env:
MERGE_BASE: ${{ steps.revisions.outputs.merge_base }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if git diff --quiet "$MERGE_BASE" "$HEAD_SHA" -- model_prices_and_context_window.json; then
echo "cost_map=false" >> "$GITHUB_OUTPUT"
else
echo "cost_map=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.changed.outputs.cost_map == 'true'
run: rustup toolchain install --no-self-update
- name: Load the cost map through model-catalog
if: steps.changed.outputs.cost_map == 'true'
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git show "${HEAD_SHA}:model_prices_and_context_window.json" > "$RUNNER_TEMP/model_prices_and_context_window.json"
cargo run --locked --quiet --manifest-path litellm-rust/Cargo.toml -p litellm-model-catalog --bin check-registry \
-- "$RUNNER_TEMP/model_prices_and_context_window.json"

View file

@ -322,6 +322,7 @@ jobs:
tests/unit/llms/openai_like
tests/unit/llms/opencode
tests/unit/llms/opensandbox
tests/unit/llms/opensearch
tests/unit/llms/openrouter
tests/unit/llms/ovhcloud
tests/unit/llms/parallel_ai

View file

@ -24,6 +24,7 @@ ENV UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \

View file

@ -40,6 +40,7 @@ ENV UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \

View file

@ -4345,6 +4345,7 @@ dependencies = [
"litellm-inference-transcription",
"litellm-llms",
"litellm-llms-types",
"litellm-model-catalog",
"litellm-router-types",
"litellm-secrets",
"litellm-secrets-aws",

View file

@ -53,6 +53,7 @@ pub(super) const RULES: &[Rule] = &[
|mapping| {
mapping.error_str.contains("invalid_request_error")
&& !mapping.error_str.contains("Incorrect API key provided")
&& !matches!(mapping.status, Some(401 | 404))
},
PublicError::BadRequest,
),
@ -132,6 +133,23 @@ mod tests {
assert_eq!(classified(Some(400), text), Some(expected));
}
#[rstest::rstest]
#[case::unauthorized(Some(401), None)]
#[case::not_found(Some(404), None)]
#[case::forbidden(Some(403), Some(PublicError::BadRequest))]
#[case::rate_limited(Some(429), Some(PublicError::BadRequest))]
#[case::server_error(Some(500), Some(PublicError::BadRequest))]
#[case::no_status(None, Some(PublicError::BadRequest))]
fn invalid_request_errors_leave_401_and_404_to_the_status_mapping(
#[case] status: Option<u16>,
#[case] expected: Option<PublicError>,
) {
assert_eq!(
classified(status, "invalid_request_error bad field"),
expected
);
}
#[rstest::rstest]
#[case::rate_limit_before_context_window(
"rate limit and This model's maximum context length is 10",

View file

@ -25,3 +25,5 @@ A new `N`, image size or resolution in an existing key shape parses with no Rust
## Tests
`BUNDLED_REGISTRY` embeds the root `model_prices_and_context_window.json`, so the tests over it fail a JSON change with a bad value or a key no type reads
`Catalog::problems` is the one definition of a mergeable cost map. The bundled test asserts it is empty, and the cost map guard workflow runs it on a pull request's JSON through `cargo run --bin check-registry <path>`

View file

@ -10,6 +10,7 @@ schema = ["dep:schemars", "litellm-cost/schema"]
[dependencies]
litellm-cost = { workspace = true, features = ["serde"] }
litellm-error.workspace = true
indexmap = { version = "2.14.0", features = ["serde"] }
jiff = { version = "0.2.37", default-features = false, features = ["std", "tzdb-bundle-always"] }
@ -24,5 +25,4 @@ time.workspace = true
[dev-dependencies]
jsonschema = { version = "0.55.1", default-features = false }
litellm-error.workspace = true
rstest.workspace = true

View file

@ -0,0 +1,26 @@
use std::{env, fs, path::PathBuf, process::ExitCode};
use litellm_model_catalog::Catalog;
fn main() -> ExitCode {
let Some(path) = env::args_os().nth(1).map(PathBuf::from) else {
eprintln!("usage: check-registry <model_prices_and_context_window.json>");
return ExitCode::from(2);
};
let body = match fs::read(&path) {
Ok(body) => body,
Err(error) => {
eprintln!("{}: {error}", path.display());
return ExitCode::FAILURE;
}
};
let problems = Catalog::problems(&body);
for problem in &problems {
eprintln!("{}", litellm_error::Report::new(problem));
}
if problems.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}

View file

@ -5,7 +5,7 @@ use indexmap::IndexMap;
use serde::Deserialize;
use serde_json::Value;
use crate::error::{Error, Loaded, Rejects, Repr};
use crate::error::{Error, Loaded, Rejects, Repr, pointer_child, pointer_for_key};
use crate::fallback::{FallbackGeneralizations, FallbackRule};
use crate::model::Model;
use crate::store::{Rows, Store};
@ -211,6 +211,36 @@ impl Catalog {
})
}
pub fn problems(body: &[u8]) -> Vec<Error> {
let Loaded {
value: catalog,
errors,
} = match Self::parse(body) {
Ok(loaded) => loaded,
Err(fatal) => return vec![fatal],
};
let unread = catalog.rows().entries.iter().flat_map(|(model, entry)| {
EntryWire::read(entry.fields())
.unknown
.into_iter()
.map(move |(key, _)| {
Error::new(
pointer_child(&pointer_for_key(model), &key),
Repr::UnreadField,
)
})
});
let no_fallback_rules = catalog
.fallback_rules()
.is_none_or(<[_]>::is_empty)
.then(|| Error::from_key(FALLBACK_GENERALIZATIONS, Repr::NoFallbackRules));
errors
.into_iter()
.chain(unread)
.chain(no_fallback_rules)
.collect()
}
pub fn row_errors(model: &str, fields: &Fields) -> Vec<Error> {
let (_, _, errors) = read_row(fields);
errors
@ -404,3 +434,85 @@ fn unknown_timezone(wire: &EntryWire) -> Option<Error> {
)
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
const RULES: &str = r#""fallback_generalizations":{"rules":[{"name":"family","pattern":"^new-","model_info":{"mode":"chat"}}]}"#;
#[rstest]
#[case::clean(format!(r#"{{{RULES},"m":{{"litellm_provider":"test"}}}}"#), vec![])]
#[case::not_json("{".to_owned(), vec![("", "json")])]
#[case::row_error(format!(r#"{{{RULES},"m":{{}}}}"#), vec![("/m/litellm_provider", "missing_provider")])]
#[case::unread_key(
format!(r#"{{{RULES},"m":{{"litellm_provider":"test","typo_cost":1}}}}"#),
vec![("/m/typo_cost", "unread_field")]
)]
#[case::empty_model_key(
format!(r#"{{{RULES},"":{{"litellm_provider":"test","future_field":true}}}}"#),
vec![("//future_field", "unread_field")]
)]
#[case::unread_key_on_invalid_row(
format!(r#"{{{RULES},"m":{{"typo_cost":1}}}}"#),
vec![("/m/litellm_provider", "missing_provider"), ("/m/typo_cost", "unread_field")]
)]
#[case::escaped_unread_key_on_invalid_row(
format!(r#"{{{RULES},"open/router~v1":{{"typo/cost~":1}}}}"#),
vec![("/open~1router~0v1/litellm_provider", "missing_provider"), ("/open~1router~0v1/typo~1cost~0", "unread_field")]
)]
#[case::no_rules(
r#"{"m":{"litellm_provider":"test"}}"#.to_owned(),
vec![("/fallback_generalizations", "no_fallback_rules")]
)]
#[case::empty_rules(
r#"{"fallback_generalizations":{"rules":[]},"m":{"litellm_provider":"test"}}"#.to_owned(),
vec![("/fallback_generalizations", "no_fallback_rules")]
)]
fn problems_list_what_a_cost_map_change_must_fix(
#[case] body: String,
#[case] expected: Vec<(&str, &str)>,
) {
let problems: Vec<_> = Catalog::problems(body.as_bytes())
.iter()
.map(|problem| (problem.path().to_owned(), <&str>::from(problem.kind())))
.collect();
let expected: Vec<_> = expected
.into_iter()
.map(|(path, kind)| (path.to_owned(), kind))
.collect();
assert_eq!(problems, expected);
}
#[rstest]
fn runtime_parsing_preserves_custom_fields_and_recovers_valid_rows() {
let body = br#"{
"good":{"litellm_provider":"test","custom_metadata":true},
"bad":{"litellm_provider":"test","input_cost_per_token":"wrong"}
}"#;
let loaded = Catalog::parse(body).unwrap();
assert_eq!(loaded.errors.len(), 1);
assert_eq!(loaded.errors[0].path(), "/bad/input_cost_per_token");
let (catalog, rejected) = loaded.value.without_rejected(&loaded.errors);
assert_eq!(rejected.len(), 1);
assert!(catalog.lookup("bad").is_none());
let good = catalog.lookup("good").unwrap();
assert!(good.entry.model().is_some());
assert_eq!(
good.entry.field("custom_metadata"),
Some(&Value::Bool(true))
);
assert!(
Catalog::problems(body)
.iter()
.any(|problem| problem.kind() == crate::ErrorKind::UnreadField)
);
assert!(
Catalog::problems(body)
.iter()
.any(|problem| problem.kind() == crate::ErrorKind::NoFallbackRules)
);
}
}

View file

@ -109,6 +109,12 @@ pub(crate) enum Repr {
#[source]
cause: Option<DateCause>,
},
#[error("not a field litellm reads")]
#[strum_discriminants(strum(serialize = "unread_field"))]
UnreadField,
#[error("has no rules")]
#[strum_discriminants(strum(serialize = "no_fallback_rules"))]
NoFallbackRules,
}
/// What a problem takes out of the served map
@ -148,7 +154,7 @@ impl Error {
| Repr::NoOffPeakHours
| Repr::EmptyList
| Repr::InvalidDate { .. } => Rejects::Row,
Repr::MissingProvider => Rejects::Nothing,
Repr::MissingProvider | Repr::UnreadField | Repr::NoFallbackRules => Rejects::Nothing,
}
}
}
@ -334,6 +340,8 @@ mod tests {
#[case::no_off_peak_hours(Repr::NoOffPeakHours, Rejects::Row)]
#[case::empty_list(Repr::EmptyList, Rejects::Row)]
#[case::invalid_date(Repr::InvalidDate { cause: None }, Rejects::Row)]
#[case::unread_field(Repr::UnreadField, Rejects::Nothing)]
#[case::no_fallback_rules(Repr::NoFallbackRules, Rejects::Nothing)]
fn policy_classifies_each_error(#[case] error: Repr, #[case] expected: Rejects) {
assert_eq!(Error::from(error).rejects(), expected);
}

View file

@ -366,18 +366,14 @@ fn written_is_the_document_in_order_including_sections_and_bad_entries() {
}
#[rstest]
fn the_bundled_registry_parses_with_no_problems_and_every_key_understood() {
let loaded = Catalog::parse(BUNDLED_REGISTRY).unwrap();
let catalog = loaded.value;
assert!(loaded.errors.is_empty(), "{:#?}", loaded.errors);
let unrecognized: Vec<_> = catalog.unrecognized().collect();
assert!(unrecognized.is_empty(), "{unrecognized:#?}");
assert!(
catalog
.fallback_rules()
.is_some_and(|rules| !rules.is_empty())
);
fn the_bundled_registry_has_no_problems() {
let problems: Vec<_> = Catalog::problems(BUNDLED_REGISTRY)
.iter()
.map(report)
.collect();
assert!(problems.is_empty(), "{problems:#?}");
const REFERENCE_MODEL_COUNT: usize = 4303;
let catalog = Catalog::parse(BUNDLED_REGISTRY).unwrap().value;
assert!(catalog.model_count() as f64 >= REFERENCE_MODEL_COUNT as f64 * 0.9);
}

View file

@ -106,6 +106,18 @@ GIL handling to `litellm-host-python`.
## Data Handling
- Reuse large immutable bundled data and parsed catalogs across calls, including in
replacement APIs. Share Rust storage and cache repeated Python projections instead
of reparsing, serializing or copying the full payload on every access
- Cache owned Python objects with `PyOnceLock<Py<T>>`, then bind and clone references
while attached. Do not use blocking `OnceLock::get_or_init` while attached
([PyO3 initialization](https://pyo3.rs/v0.29.2/faq.html),
[reference ownership](https://pyo3.rs/v0.29.2/types.html))
- Never reuse Python objects across interpreters or interpreter reinitialization.
Once-only caches are for immutable data. Reloadable/custom data needs versioned
entries or invalidation, and caller-mutable Python containers must not be shared
- Test unchanged contents and allocation reuse, not just equal values. Keep this
coverage when replacing a bundled-data accessor with a catalog API
- OCR payloads can contain personal data and large base64 images. Do not log
payloads or provider responses.
- Avoid copying large payloads more than needed. The current JSON round-trip is

View file

@ -49,6 +49,7 @@ litellm-secrets-types.workspace = true
litellm-llms-types.workspace = true
litellm-router-types.workspace = true
litellm-host-python.workspace = true
litellm-model-catalog.workspace = true
litellm-token-counter = { path = "../token-counter", default-features = false }
pyo3.workspace = true
prost.workspace = true

View file

@ -8,6 +8,7 @@ mod execution;
mod http;
mod logger;
mod marshal;
mod model_registry;
mod preflight;
mod python_settings;
mod routes;
@ -30,6 +31,8 @@ mod _native {
#[pymodule_export]
use crate::logger::NativeDiagnosticProcessor;
#[pymodule_export]
use crate::model_registry::bundled_model_cost_map;
#[pymodule_export]
use crate::routes::audio_transcription::{atranscription, transcription};
#[pymodule_export]
use crate::routes::chat_completions::{acompletion, completion};
@ -110,6 +113,7 @@ mod tests {
"NativeClickHouseSpendConfig",
"NativeClickHouseSpendStorage",
"trace_encode_error",
"bundled_model_cost_map",
"TokenCounter",
"Tokenizer",
"gil_stats",

View file

@ -0,0 +1,30 @@
use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
use pyo3::types::PyBytes;
#[pyfunction]
pub fn bundled_model_cost_map(py: Python<'_>) -> Bound<'_, PyBytes> {
static BYTES: PyOnceLock<Py<PyBytes>> = PyOnceLock::new();
BYTES
.get_or_init(py, || {
PyBytes::new(py, litellm_model_catalog::BUNDLED_REGISTRY).unbind()
})
.bind(py)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
fn repeated_calls_reuse_the_bundled_registry_bytes() {
Python::initialize();
let first = Python::attach(|py| bundled_model_cost_map(py).unbind());
Python::attach(|py| {
let second = bundled_model_cost_map(py);
assert!(first.bind(py).is(&second));
assert_eq!(second.as_bytes(), litellm_model_catalog::BUNDLED_REGISTRY);
});
}
}

View file

@ -139,6 +139,7 @@ class AuthenticationError(openai.AuthenticationError):
litellm_debug_info: str | None = None,
max_retries: int | None = None,
num_retries: int | None = None,
body: Mapping[str, object] | None = None,
):
self.status_code = 401
self.message = f"litellm.AuthenticationError: {message}"
@ -152,7 +153,7 @@ class AuthenticationError(openai.AuthenticationError):
request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object
)
super().__init__(
self.message, response=self.response, body=None
self.message, response=self.response, body=body
) # Call the base class constructor with the parameters it needs
def __str__(self):
@ -189,6 +190,7 @@ class NotFoundError(openai.NotFoundError):
litellm_debug_info: str | None = None,
max_retries: int | None = None,
num_retries: int | None = None,
body: Mapping[str, object] | None = None,
):
self.status_code = 404
self.message = f"litellm.NotFoundError: {message}"
@ -202,7 +204,7 @@ class NotFoundError(openai.NotFoundError):
request=httpx.Request(method="GET", url="https://litellm.ai"), # mock request object
)
super().__init__(
self.message, response=self.response, body=None
self.message, response=self.response, body=body
) # Call the base class constructor with the parameters it needs
def __str__(self):
@ -321,6 +323,17 @@ class UnsupportedEndpointError(BadRequestError):
pass
class EndpointNotSupportedByModelError(BadRequestError):
def __init__(self, message: str, model: str, llm_provider: str = "") -> None:
super().__init__( # pyright: ignore[reportUnknownMemberType] # inherited public exception constructor is untyped
message=message, model=model, llm_provider=llm_provider
)
self.message = message
self.type = "invalid_request_error"
self.param = "model"
self.code = "400"
class UnprocessableEntityError(openai.UnprocessableEntityError):
def __init__(
self,

View file

@ -413,6 +413,25 @@ def _map_openai_exception(
body=getattr(original_exception, "body", None),
)
elif "invalid_request_error" in error_str and "Incorrect API key provided" not in error_str:
invalid_request_status: Final = getattr(original_exception, "status_code", None)
if invalid_request_status == 401:
raise AuthenticationError(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,
model=model,
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
if invalid_request_status == 404:
raise NotFoundError(
message=f"{exception_provider} - {message}",
model=model,
llm_provider=custom_llm_provider,
response=response,
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
raise BadRequestError(
message=f"{exception_provider} - {message}",
llm_provider=custom_llm_provider,

View file

@ -31,7 +31,13 @@ EXTRACTION_HEALTH_CHECK_FIELDS: Final[Mapping[str, Mapping[str, str]]] = Mapping
)
def native_health_check_mode(model: str, custom_llm_provider: str | None) -> Literal["anthropic_messages"] | None:
def native_health_check_mode(
model: str, custom_llm_provider: str | None
) -> Literal["anthropic_messages", "realtime"] | None:
if custom_llm_provider == "soniox":
from litellm.llms.soniox.common_utils import soniox_health_check_mode
return soniox_health_check_mode(model)
if custom_llm_provider != "bedrock_mantle":
return None
from litellm.llms.bedrock_mantle.common_utils import mantle_health_check_mode

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.llms.ollama.common_utils import OllamaResponseIdNormalizer
from litellm.types.llms.databricks import DatabricksTool
from litellm.types.llms.openai import (
ChatCompletionAnnotation,
ChatCompletionReasoningItem,
ChatCompletionThinkingBlock,
ImageURLListItem,
@ -59,6 +60,31 @@ _CHOICES_FIELDS: Final[frozenset] = frozenset(Choices.model_fields.keys())
_MODEL_RESPONSE_FIELDS: Final[frozenset] = frozenset(ModelResponse.model_fields.keys()) | {"usage"}
_ANNOTATION_ENTRIES: Final = TypeAdapter(tuple[object, ...])
def _is_provider_specific_annotation(annotation: object) -> bool:
match annotation:
case {"type": "url_citation"}:
return False
case {"type": _}:
return True
case _:
return False
def _annotation_entries(annotations: object) -> tuple[object, ...]:
return _ANNOTATION_ENTRIES.validate_python(annotations) if isinstance(annotations, list) else ()
def partition_annotations(annotations: object) -> tuple[object, tuple[object, ...]]:
entries: Final = _annotation_entries(annotations)
others: Final = tuple(annotation for annotation in entries if _is_provider_specific_annotation(annotation))
if not others:
return annotations, ()
return [annotation for annotation in entries if not _is_provider_specific_annotation(annotation)] or None, others
def _normalize_images_for_message(
images: list[dict] | None,
) -> list[ImageURLListItem] | None:
@ -787,6 +813,9 @@ def convert_to_model_response_object(
provider_specific_fields = dict(choice["message"].get("provider_specific_fields", None) or {})
for f in choice["message"].keys() - _MESSAGE_FIELDS:
provider_specific_fields[f] = choice["message"][f]
annotations, other_annotations = partition_annotations(choice["message"].get("annotations", None))
if other_annotations:
provider_specific_fields["annotations"] = list(other_annotations)
# Handle reasoning models that display `reasoning_content` within `content`
reasoning_content, content = extract_reasoning_content(choice["message"])
@ -809,7 +838,9 @@ def convert_to_model_response_object(
reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks,
reasoning_items=choice["message"].get("reasoning_items", None),
annotations=choice["message"].get("annotations", None),
annotations=cast( # cast-ok: provider annotation dicts pass through whole, keeping keys such as url_citation.content
list[ChatCompletionAnnotation] | None, annotations
),
images=_normalize_images_for_message(choice["message"].get("images", None)),
)
finish_reason = choice.get("finish_reason", None)

View file

@ -424,6 +424,109 @@ def anthropic_image_source_to_openai_url(image_source: Mapping[str, object]) ->
return None
@dataclass(frozen=True, slots=True)
class InlineDocument:
filename: str
file_data: str
def _document_media_type(source: object) -> str:
match source:
case {"media_type": str() as media_type} if media_type:
return media_type
case _:
return "application/pdf"
def anthropic_inline_document(document: Mapping[str, object]) -> InlineDocument | None:
source: Final = document.get("source")
match source:
case {"type": "base64", "data": str() as data} if data:
title: Final = document.get("title")
return InlineDocument(
filename=title if isinstance(title, str) and title else "document.pdf",
file_data=f"data:{_document_media_type(source)};base64,{data}",
)
case _:
return None
def is_pdf_file_part(part: object) -> bool:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data.startswith("data:"):
return file_data.startswith("data:application/pdf")
case {"type": "file", "file": {"format": str() as file_format}}:
return file_format == "application/pdf"
case {"type": "file", "file": {"filename": str() as filename}}:
return filename.lower().endswith(".pdf")
case _:
return False
_CONTENT_PARTS: Final = TypeAdapter(tuple[object, ...])
def content_parts(message: Mapping[str, object]) -> Sequence[object]:
content: Final = message.get("content")
return _CONTENT_PARTS.validate_python(content) if isinstance(content, list) else ()
def _has_pdf_file_part(message: Mapping[str, object]) -> bool:
return any(is_pdf_file_part(part) for part in content_parts(message))
def messages_have_pdf_file_part(messages: Sequence[Mapping[str, object]]) -> bool:
return any(_has_pdf_file_part(message) for message in messages)
def _message_without_pdf_file_parts(message: AllMessageValues) -> AllMessageValues:
if not _has_pdf_file_part(message):
return message
kept: Final = [part for part in content_parts(message) if not is_pdf_file_part(part)]
return cast(AllMessageValues, {**message, "content": kept}) # cast-ok: same message with fewer content parts
def _is_pdf_only_user_turn(message: AllMessageValues) -> bool:
parts: Final = content_parts(message)
return message["role"] == "user" and bool(parts) and all(is_pdf_file_part(part) for part in parts)
def messages_without_pdf_file_parts(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
return tuple(
_message_without_pdf_file_parts(message) for message in messages if not _is_pdf_only_user_turn(message)
)
_CONTENT_PART: Final = TypeAdapter(Mapping[str, object])
def _file_part_as_image_url(part: object) -> object:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data:
fields: Final = _CONTENT_PART.validate_python(part)
return {key: value for key, value in fields.items() if key != "file"} | {
"type": "image_url",
"image_url": {"url": file_data},
}
case _:
return part
def message_with_file_parts_as_image_urls(message: Mapping[str, object]) -> Mapping[str, object]:
parts: Final = content_parts(message)
if not parts:
return message
return {**message, "content": [_file_part_as_image_url(part) for part in parts]}
def _typed_message_with_file_parts_as_image_urls(message: AllMessageValues) -> AllMessageValues:
return cast(AllMessageValues, message_with_file_parts_as_image_urls(message)) # cast-ok: same message, image parts
def messages_with_file_parts_as_image_urls(messages: Sequence[AllMessageValues]) -> tuple[AllMessageValues, ...]:
return tuple(_typed_message_with_file_parts_as_image_urls(message) for message in messages)
def _image_part_url(part: Mapping[str, object]) -> str | None:
"""The image URL carried by one content part, whichever of the three dialects wrote it."""
part_type: Final = part.get("type")

View file

@ -20,6 +20,7 @@ from litellm.constants import (
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
)
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.base_llm.realtime.transcription_protocol import RealtimeTranscriptionProtocolError
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig, RealtimeBackend
from litellm.types.llms.openai import (
OpenAIRealtimeEvents,
@ -1281,24 +1282,29 @@ class RealTimeStreaming:
verbose_logger.exception("Connection closed in backend to client send messages - %s", e)
close: Final = backend_close_from(e)
self._flush_unbilled_transcription_usage()
if self._backend_refused_session(close):
await self.log_backend_refusal(e)
else:
await self.log_messages()
await self._log_session_end(e if self._backend_refused_session(close) else None)
return close
except asyncio.CancelledError:
self._flush_unbilled_transcription_usage()
await self.log_messages()
await self._log_session_end(None)
raise
except Exception as e:
verbose_logger.exception("Error in backend to client send messages: %s", e)
self._flush_unbilled_transcription_usage()
await self.log_messages()
await self._log_session_end(None)
return BackendClose(code=1011, reason="proxy failed while relaying the upstream websocket")
def _backend_refused_session(self, close: BackendClose) -> bool:
return close.code != 1000 and not self._backend_sent_frames
async def _log_session_end(self, transport_refusal: Exception | None) -> None:
provider_refusal: Final = None if self.provider_config is None else self.provider_config.session_refusal()
refusal: Final = provider_refusal or transport_refusal
if refusal is None:
await self.log_messages()
return
await self.log_backend_refusal(refusal)
async def log_backend_refusal(self, error: Exception) -> None:
if not self.logging_obj:
return
@ -1691,7 +1697,13 @@ class RealTimeStreaming:
# ``_maybe_send_guardrail_turn_detection_update`` would retry.
if msg_type == "session.update" and self.provider_config is None:
await self._send_session_gate_once()
sent = await self._send_to_backend(message)
try:
sent = await self._send_to_backend(message)
except RealtimeTranscriptionProtocolError as e:
await self.websocket.send_text(realtime_error_event(str(e), error_type="invalid_request_error"))
if self._uses_deferred_backend_setup():
raise
continue
if guardrail_turn_detection_injected and sent:
self._guardrail_turn_detection_update_sent = True

View file

@ -20,6 +20,7 @@ from litellm.litellm_core_utils.core_helpers import (
)
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import BEDROCK_TRACE_FIELDS, object_items, object_mapping
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import partition_annotations
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
)
@ -1703,10 +1704,17 @@ def stream_chunk_builder(
# Merge annotations from ALL chunks — providers may spread
# them across multiple streaming chunks or send them only in
# the final chunk.
all_annotations: Final[list] = []
all_annotations: Final[list[object]] = []
for ac in annotation_chunks:
all_annotations.extend(ac["choices"][0]["delta"]["annotations"])
response["choices"][0]["message"]["annotations"] = all_annotations
message_annotations, provider_annotations = partition_annotations(all_annotations)
if message_annotations is not None:
response["choices"][0]["message"]["annotations"] = message_annotations
if provider_annotations:
response.choices[0].message.provider_specific_fields = {
**(response.choices[0].message.provider_specific_fields or {}),
"annotations": list(provider_annotations),
}
audio_chunks: Final = [
chunk
@ -1769,7 +1777,10 @@ def stream_chunk_builder(
if combined_provider_fields:
_choice = response.choices[0]
_choice.message.provider_specific_fields = combined_provider_fields
_choice.message.provider_specific_fields = {
**(_choice.message.provider_specific_fields or {}),
**combined_provider_fields,
}
completion_output = get_content_from_model_response(response)

View file

@ -123,6 +123,7 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
from litellm.litellm_core_utils.prompt_templates.common_utils import (
anthropic_image_source_to_openai_url,
anthropic_inline_document,
parse_tool_call_arguments,
reasoning_content_from_thinking_blocks,
with_prompt_cache_breakpoint,
@ -185,6 +186,8 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionFileObject,
ChatCompletionFileObjectFile,
ChatCompletionImageObject,
ChatCompletionImageUrlObject,
ChatCompletionRedactedThinkingBlock,
@ -498,7 +501,9 @@ class LiteLLMAnthropicMessagesAdapter:
for m in ordered_messages:
user_message: ChatCompletionUserMessage | None = None
tool_message_list: list[ChatCompletionToolMessage] = []
new_user_content_list: list[ChatCompletionTextObject | ChatCompletionImageObject] = []
new_user_content_list: list[
ChatCompletionTextObject | ChatCompletionImageObject | ChatCompletionFileObject
] = []
if m["role"] == "system":
system_message = self._translate_midturn_system_message_to_openai(m, model)
if system_message is not None:
@ -531,13 +536,8 @@ class LiteLLMAnthropicMessagesAdapter:
self._add_prompt_cache_breakpoint_if_present(content, image_obj)
)
elif content.get("type") == "document":
# Convert Anthropic document format (PDF, etc.) to OpenAI format
source = content.get("source", {})
openai_image_url = self._translate_anthropic_image_to_openai(cast(dict, source))
if openai_image_url:
image_url_obj = ChatCompletionImageUrlObject(url=openai_image_url)
doc_obj = ChatCompletionImageObject(type="image_url", image_url=image_url_obj)
doc_obj = self._translate_anthropic_document_to_openai(content)
if doc_obj is not None:
self._add_cache_control_if_applicable(content, doc_obj, model)
new_user_content_list.append(doc_obj)
elif content.get("type") == "tool_result":
@ -1357,7 +1357,7 @@ class LiteLLMAnthropicMessagesAdapter:
text: Final = tool_result_text_part(block)
return None if text is None else ChatCompletionTextObject(type="text", text=text)
case "image" | "document":
return self._tool_result_image_part(block.get("source"))
return self._source_image_url_part(block.get("source"))
case "tool_reference":
return ChatCompletionToolReferenceObject(
type="tool_reference", tool_name=str(block.get("tool_name") or "")
@ -1365,7 +1365,18 @@ class LiteLLMAnthropicMessagesAdapter:
case _:
return None
def _tool_result_image_part(self, image_source: object) -> ChatCompletionImageObject | None:
def _translate_anthropic_document_to_openai(
self, document: Mapping[str, object]
) -> ChatCompletionFileObject | ChatCompletionImageObject | None:
inline_document: Final = anthropic_inline_document(document)
if inline_document is None:
return self._source_image_url_part(document.get("source"))
return ChatCompletionFileObject(
type="file",
file=ChatCompletionFileObjectFile(filename=inline_document.filename, file_data=inline_document.file_data),
)
def _source_image_url_part(self, image_source: object) -> ChatCompletionImageObject | None:
if not isinstance(image_source, dict):
return None
openai_image_url = self._translate_anthropic_image_to_openai(image_source)

View file

@ -13,6 +13,7 @@ from typing import Any, Final, cast
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_BOUNDARY,
TOOL_RESULT_IMAGE_PLACEHOLDER,
anthropic_inline_document,
encrypted_reasoning_signature,
responses_reasoning_items_from_thinking_blocks,
with_prompt_cache_breakpoint,
@ -106,27 +107,18 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
block: Mapping[str, object],
) -> dict[str, str] | None: # mutable-ok: API message payload
"""Convert an Anthropic document block to a Responses input_file part."""
inline_document: Final = anthropic_inline_document(block)
if inline_document is not None:
return {
"type": "input_file",
"filename": inline_document.filename,
"file_data": inline_document.file_data,
}
raw_source: Final = block.get("source")
if not isinstance(raw_source, Mapping):
return None
source: Final = cast(Mapping[str, object], raw_source) # cast-ok: untrusted client payload
source_type: Final = source.get("type")
if source_type == "base64":
data: Final = source.get("data")
if not isinstance(data, str) or not data:
return None
raw_media_type: Final = source.get("media_type")
media_type: Final = (
raw_media_type if isinstance(raw_media_type, str) and raw_media_type else "application/pdf"
)
raw_title: Final = block.get("title")
filename: Final = raw_title if isinstance(raw_title, str) and raw_title else "document.pdf"
return {
"type": "input_file",
"filename": filename,
"file_data": f"data:{media_type};base64,{data}",
}
if source_type == "url":
if source.get("type") == "url":
url: Final = source.get("url")
if not isinstance(url, str) or not url:
return None

View file

@ -1,7 +1,7 @@
import copy
import enum
import re
from collections.abc import AsyncIterator, Collection, Iterator
from collections.abc import AsyncIterator, Collection, Iterator, Mapping
from typing import TYPE_CHECKING, Final, cast
import httpx
@ -10,10 +10,11 @@ from httpx import Response
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
audio_or_image_in_message_content,
content_parts,
convert_content_list_to_str,
drop_tool_reference_parts_from_tool_messages,
filter_value_from_dict,
is_pdf_file_part,
)
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.azure_ai.common_utils import (
@ -46,6 +47,20 @@ NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = (
"cache_control",
)
LIST_ONLY_CONTENT_PART_TYPES: Final = frozenset({"image_url", "input_audio"})
def _is_list_only_content_part(part: object) -> bool:
match part:
case {"type": str() as part_type} if part_type in LIST_ONLY_CONTENT_PART_TYPES:
return True
case _:
return is_pdf_file_part(part)
def _has_list_only_content_part(message: Mapping[str, object]) -> bool:
return any(_is_list_only_content_part(part) for part in content_parts(message))
class AzureAIChatCompletionResponseIterator(OpenAIChatCompletionResponseIterator):
def chunk_parser(self, chunk: dict[str, object]) -> ModelResponseStream: # mutable-ok: mirrors override contract
@ -246,7 +261,7 @@ class AzureAIStudioConfig(OpenAIConfig):
these with "Extra inputs are not permitted", which breaks multi-turn
Anthropic-format clients that echo thinking blocks back as history.
2. Transforms list content to a string.
3. If message contains an image or audio, send as is (user-intended)
3. If message contains an image, audio, or PDF file part, send as is (user-intended)
Operates on a deep copy so the caller's messages keep their thinking blocks
and provider metadata, which a fallback to another provider still needs.
@ -257,8 +272,7 @@ class AzureAIStudioConfig(OpenAIConfig):
for field in NON_OPENAI_SPEC_MESSAGE_FIELDS:
filter_value_from_dict(message_dict, field)
# Do nothing if the message contains an image or audio
if audio_or_image_in_message_content(message):
if _has_list_only_content_part(message):
continue
texts = convert_content_list_to_str(message=message)

View file

@ -4,7 +4,6 @@ from types import TracebackType
from typing import TYPE_CHECKING, Any, Protocol
import httpx
from typing_extensions import Self
from litellm.types.llms.openai import OpenAIRealtimeStreamSessionEvents
from litellm.types.realtime import (
@ -24,7 +23,7 @@ else:
class RealtimeBackend(Protocol):
async def __aenter__(self) -> Self: ...
async def __aenter__(self) -> object: ...
async def __aexit__(
self,
@ -97,9 +96,15 @@ class BaseRealtimeConfig(ABC):
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return None
def session_refusal(self) -> Exception | None:
return None
async def open_backend(self, url: str, headers: Mapping[str, str]) -> RealtimeBackend | None:
return None
def wrap_backend(self, backend: RealtimeBackend) -> RealtimeBackend:
return backend
def transform_session_created_event(
self,
model: str,

View file

@ -226,6 +226,9 @@ class BaseVectorStoreConfig:
``get_complete_url`` adds a query string, like Azure, override this."""
return f"{api_base}/{encoded_vector_store_id}"
def get_vector_store_list_url(self, api_base: str) -> str:
return api_base
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
from ..chat.transformation import BaseLLMException

View file

@ -6923,7 +6923,7 @@ class BaseLLMHTTPHandler:
try:
ssl_context: Final = realtime_ssl_for_url(url)
provider_backend: Final = await provider_config.open_backend(url, headers)
backend_ws: Final = (
backend_ws: Final = provider_config.wrap_backend(
provider_backend
if provider_backend is not None
else await self._open_realtime_backend_ws(websockets, url, headers, ssl_context)
@ -6961,9 +6961,7 @@ class BaseLLMHTTPHandler:
await backend_ws.send(_session_config)
realtime_streaming.session_configuration_request = _session_config
# For providers that defer setup until client session.update, optionally
# send synthetic session.created to unblock clients waiting on connect.
if not provider_config.requires_session_configuration():
if not _session_config:
synthetic_session: Final = provider_config.transform_session_created_event(
model=model,
logging_session_id=logging_obj.litellm_trace_id,
@ -11152,7 +11150,7 @@ class BaseLLMHTTPHandler:
litellm_params=dict(litellm_params),
)
url: Final = api_base
url: Final = vector_store_provider_config.get_vector_store_list_url(api_base)
params: Final[dict[str, object]] = {}
if after is not None:
@ -11231,7 +11229,7 @@ class BaseLLMHTTPHandler:
litellm_params=dict(litellm_params),
)
url: Final = api_base
url: Final = vector_store_provider_config.get_vector_store_list_url(api_base)
params: Final[dict[str, object]] = {}
if after is not None:

View file

@ -38,6 +38,15 @@ else:
_EMPTY_FUNCTION: Final[Mapping[str, object]] = MappingProxyType({})
_JSON_VALUE: Final = TypeAdapter(object)
_CONTENT_PART: Final = TypeAdapter(Mapping[str, object])
def _file_part_data(part: Mapping[str, object]) -> str | None:
match part:
case {"type": "file", "file": {"file_data": str() as file_data}} if file_data:
return file_data
case _:
return None
def is_valid_json(value: str) -> bool:
@ -295,7 +304,7 @@ class GigaChatConfig(BaseConfig):
def _transform_list_content(self, content: Sequence) -> tuple[str, Sequence[object]]:
"""
Extract text and image attachments from a multimodal message content list.
Extract text, image and file attachments from a multimodal message content list.
Args:
content: List of content parts (OpenAI multimodal format)
@ -320,6 +329,10 @@ class GigaChatConfig(BaseConfig):
file_id = self._upload_image(upload_url)
if file_id:
attachments.append(file_id)
elif (file_data := _file_part_data(_CONTENT_PART.validate_python(part))) and (
uploaded_file_id := self._upload_image(file_data)
):
attachments.append(uploaded_file_id)
text: Final = "\n".join(texts) if texts else ""
return text, attachments

View file

@ -20,6 +20,7 @@ import litellm
from litellm._logging import verbose_logger
from litellm.constants import DEFAULT_OCI_CHAT_MAX_TOKENS
from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
from litellm.litellm_core_utils.prompt_templates.common_utils import messages_with_file_parts_as_image_urls
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
@ -681,7 +682,9 @@ class OCIChatConfig(BaseConfig):
servingMode=serving_mode,
chatRequest=OCIChatRequestPayload(
apiFormat=vendor.value,
messages=adapt_messages_to_generic_oci_standard(messages),
messages=adapt_messages_to_generic_oci_standard(
list(messages_with_file_parts_as_image_urls(messages))
),
**self._get_optional_params(vendor, optional_params, model),
),
)

View file

View file

@ -0,0 +1,415 @@
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from functools import partial
from typing import TYPE_CHECKING, Final, Literal, NoReturn
from urllib.parse import quote, urlsplit
import httpx
from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.exceptions import AuthenticationError, BadRequestError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.vector_store.transformation import (
BaseQueryEmbeddingVectorStoreConfig,
VectorStoreEmbeddingExecutor,
)
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, SignsRequestsWithAWS, sign_aws_json_post
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.bedrock import AwsAuthParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import EmbeddingResponse
from litellm.types.vector_stores import (
BaseVectorStoreAuthCredentials,
VectorStoreCreateOptionalRequestParams,
VectorStoreIndexEndpoints,
VectorStoreResultContent,
VectorStoreSearchOptionalRequestParams,
VectorStoreSearchResponse,
VectorStoreSearchResult,
)
if TYPE_CHECKING:
from botocore.credentials import Credentials
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
DEFAULT_EMBEDDING_FIELD: Final = "embedding"
DEFAULT_TEXT_FIELD: Final = "text"
DEFAULT_MAX_NUM_RESULTS: Final = 10
MAX_MAX_NUM_RESULTS: Final = 50
_PARAM_PREFIX: Final = "opensearch_"
_AWS_HOSTS: Final = (
re.compile(r"\.(?P<region>[a-z0-9-]+)\.(?P<service>aoss|aoss-fips|es)\.amazonaws\.com(?:\.cn)?\Z"),
re.compile(r"\.(?P<service>aoss|aos)\.(?P<region>[a-z0-9-]+)\.on\.aws\Z"),
)
_ACCOUNT_ID_LABEL: Final = re.compile(r"\A\d{12}\.")
_QUERY_VECTOR: Final = TypeAdapter(tuple[float, ...])
_ATTRIBUTE_VALUE_TYPES: Final = (str, int, float, bool)
_SEARCH_ONLY_MESSAGE: Final = (
"The OpenSearch vector store is search-only. Create the k-NN index in OpenSearch, "
"then register it here with its name as opensearch_index_name or vector_store_id."
)
_PER_ACCOUNT_MESSAGE: Final = (
"api_base is an OpenSearch Serverless per-account endpoint, which needs a collection header the vector store "
"does not send. Use the collection endpoint instead, for example https://<collection-id>.<region>.aoss.amazonaws.com."
)
_SIGV4_ONLY_MESSAGE: Final = (
"The OpenSearch vector store signs every request with AWS SigV4 and does not support api_key. "
"Remove api_key and give the vector store AWS credentials instead."
)
_CREDENTIALS_HINT: Final = (
"Set aws_access_key_id and aws_secret_access_key, aws_role_name, or aws_profile_name on the vector store, "
"or run LiteLLM with an AWS identity."
)
def _bad_request(message: str) -> BadRequestError:
return BadRequestError(message=message, model=None, llm_provider="opensearch")
def _unauthenticated(reason: str) -> AuthenticationError:
return AuthenticationError(
message=f"No usable AWS credentials to sign the OpenSearch request ({reason}). {_CREDENTIALS_HINT}",
llm_provider="opensearch",
model=None,
)
def _usable_credentials(credentials: object) -> "Credentials":
from botocore.credentials import Credentials
if isinstance(credentials, Credentials):
return credentials
raise _unauthenticated("none were found")
class _OpenSearchParams(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
litellm_embedding_model: str | None = None
opensearch_embedding_field: str | None = None
opensearch_index_name: str | None = None
opensearch_text_field: str | None = None
@property
def embedding_field(self) -> str:
return self.opensearch_embedding_field or DEFAULT_EMBEDDING_FIELD
@property
def text_field(self) -> str:
return self.opensearch_text_field or DEFAULT_TEXT_FIELD
def index_name(self, vector_store_id: str) -> str:
return self.opensearch_index_name or vector_store_id
def require_embedding_model(self) -> str:
if not self.litellm_embedding_model:
raise _bad_request(
"litellm_embedding_model is required for the OpenSearch vector store. It must be the model that "
f"produced the vectors in '{self.embedding_field}', for example openai/text-embedding-3-small."
)
return self.litellm_embedding_model
class _SigningRegion(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
aws_region_name: str | None = None
class _SearchOptions(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
max_num_results: int | None = None
filters: Mapping[str, object] | None = None
ranking_options: Mapping[str, object] | None = None
rewrite_query: bool | None = None
@property
def unsupported(self) -> tuple[str, ...]:
requested: Final = (
("filters", self.filters),
("ranking_options", self.ranking_options),
("rewrite_query", self.rewrite_query),
)
return tuple(name for name, value in requested if value)
@property
def k(self) -> int:
if self.max_num_results is None:
return DEFAULT_MAX_NUM_RESULTS
if not 1 <= self.max_num_results <= MAX_MAX_NUM_RESULTS:
raise _bad_request(
f"max_num_results must be between 1 and {MAX_MAX_NUM_RESULTS}, got {self.max_num_results}"
)
return self.max_num_results
_KNOWN_PARAMS: Final = frozenset(name for name in _OpenSearchParams.model_fields if name.startswith(_PARAM_PREFIX))
class _LoggedSearch(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
query: str | tuple[str, ...] = ""
litellm_params: _OpenSearchParams = _OpenSearchParams()
@property
def search_query(self) -> str:
return BaseQueryEmbeddingVectorStoreConfig.query_text(self.query)
class _Hit(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
id: str = Field(alias="_id")
score: float | None = Field(default=None, alias="_score")
source: Mapping[str, object] = Field(default_factory=dict, alias="_source")
class _Hits(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
hits: Sequence[_Hit]
class _SearchResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
hits: _Hits
def aws_signing_target(url: str) -> tuple[Literal["aoss", "es"], str | None]:
hostname: Final = urlsplit(url).hostname or ""
match: Final = next(filter(None, (pattern.search(hostname) for pattern in _AWS_HOSTS)), None)
if match is None:
return "es", None
return ("aoss" if match["service"].startswith("aoss") else "es"), match["region"]
def _search_result(hit: _Hit, params: _OpenSearchParams) -> VectorStoreSearchResult:
text: Final = hit.source.get(params.text_field)
filename: Final = hit.source.get("filename")
return VectorStoreSearchResult(
score=hit.score,
content=[VectorStoreResultContent(type="text", text=text)] if isinstance(text, str) else [],
file_id=hit.id,
filename=filename if isinstance(filename, str) else None,
attributes={
key: value
for key, value in hit.source.items()
if key not in (params.text_field, params.embedding_field) and isinstance(value, _ATTRIBUTE_VALUE_TYPES)
},
)
class OpenSearchVectorStoreConfig(BaseQueryEmbeddingVectorStoreConfig, SignsRequestsWithAWS):
def __init__(self, aws: BaseAWSLLM | None = None) -> None:
self._aws: Final = aws if aws is not None else BaseAWSLLM()
def get_auth_credentials(self, litellm_params: Mapping[str, object]) -> BaseVectorStoreAuthCredentials:
return BaseVectorStoreAuthCredentials()
def get_vector_store_endpoints_by_type(self) -> VectorStoreIndexEndpoints:
return VectorStoreIndexEndpoints(read=[], write=[])
def validate_environment(
self, headers: Mapping[str, object], litellm_params: GenericLiteLLMParams | None
) -> dict[str, object]: # mutable-ok: the shared HTTP handler requires writable headers
params: Final = dict(litellm_params or {})
self._signing_credentials(params, self._signing_region(params, str(params.get("api_base") or "")))
return {**headers, "Content-Type": "application/json"}
def _signing_region(self, params: Mapping[str, object], api_base: str) -> str:
host_region: Final = aws_signing_target(api_base)[1]
return self._aws.get_aws_region_name_for_non_llm_api_calls(
host_region or _SigningRegion.model_validate(params).aws_region_name
)
def _signing_credentials(self, params: Mapping[str, object], region: str) -> "Credentials":
if params.get("api_key"):
raise _bad_request(_SIGV4_ONLY_MESSAGE)
auth: Final = AwsAuthParams.model_validate(params)
if bool(auth.aws_access_key_id) != bool(auth.aws_secret_access_key):
raise _unauthenticated("aws_access_key_id and aws_secret_access_key must be set together")
ensure_optional_import("botocore")
from botocore.exceptions import (
CredentialRetrievalError,
NoCredentialsError,
PartialCredentialsError,
ProfileNotFound,
)
try:
return _usable_credentials(self._aws.resolve_credentials(auth, region))
except CredentialRetrievalError as error:
verbose_logger.warning("OpenSearch vector store AWS credential source failed: %s", error)
raise _unauthenticated("the AWS credential source failed, see the LiteLLM logs") from None
except (NoCredentialsError, PartialCredentialsError, ProfileNotFound) as error:
raise _unauthenticated(str(error)) from None
def get_complete_url(self, api_base: str | None, litellm_params: Mapping[str, object]) -> str:
parsed: Final = urlsplit(api_base or "")
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise _bad_request(
"api_base must be the OpenSearch endpoint URL, for example "
"https://<collection-id>.<region>.aoss.amazonaws.com or https://<domain-endpoint>."
)
if _ACCOUNT_ID_LABEL.match(parsed.hostname) and aws_signing_target(api_base or "")[0] == "aoss":
raise _bad_request(_PER_ACCOUNT_MESSAGE)
return (api_base or "").rstrip("/")
def get_vector_store_url(self, api_base: str, encoded_vector_store_id: str) -> NoReturn:
raise _bad_request(_SEARCH_ONLY_MESSAGE)
def get_vector_store_list_url(self, api_base: str) -> NoReturn:
raise _bad_request(_SEARCH_ONLY_MESSAGE)
@staticmethod
def _params(
litellm_params: Mapping[str, object],
optional_params: VectorStoreSearchOptionalRequestParams,
extra_body: Mapping[str, object] | None,
) -> tuple[_OpenSearchParams, int]:
unknown: Final = sorted(
key for key in litellm_params if key.startswith(_PARAM_PREFIX) and key not in _KNOWN_PARAMS
)
if unknown:
raise _bad_request(
f"Unknown OpenSearch vector store parameter(s): {', '.join(unknown)}. "
f"Supported: {', '.join(sorted(_KNOWN_PARAMS))}."
)
if extra_body:
raise _bad_request("The OpenSearch vector store does not support extra_body.")
try:
options: Final = _SearchOptions.model_validate(optional_params)
except ValidationError as error:
raise _bad_request(f"Invalid vector store search parameters: {error}") from None
if options.unsupported and not drops_unsupported_params(litellm_params.get("drop_params")):
raise _bad_request(
f"The OpenSearch vector store does not support {', '.join(options.unsupported)} yet. "
"Remove it from the request, or set drop_params to drop it."
)
try:
return _OpenSearchParams.model_validate(litellm_params), options.k
except ValidationError:
raise _bad_request(
"opensearch_embedding_field, opensearch_index_name, opensearch_text_field, and litellm_embedding_model "
"must be strings."
) from None
@staticmethod
def _search_request(
vector_store_id: str,
embedding: EmbeddingResponse,
k: int,
params: _OpenSearchParams,
api_base: str,
) -> tuple[str, dict[str, object]]: # mutable-ok: the provider contract returns a writable JSON request body
vector: Final = _QUERY_VECTOR.validate_python(embedding.data[0]["embedding"]) # pyright: ignore[reportUnknownMemberType] # EmbeddingResponse.data is an untyped list, the vector is validated here
return (
f"{api_base}/{quote(params.index_name(vector_store_id), safe='')}/_search",
{
"size": k,
"query": {"knn": {params.embedding_field: {"vector": vector, "k": k}}},
"_source": {"excludes": [params.embedding_field]},
},
)
def transform_search_vector_store_request(
self,
vector_store_id: str,
query: str | Sequence[str],
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
api_base: str,
litellm_logging_obj: "LiteLLMLoggingObj",
litellm_params: Mapping[str, object],
extra_body: Mapping[str, object] | None = None,
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
) -> tuple[str, dict[str, object]]: # mutable-ok: the provider contract returns a writable JSON request body
params, k = self._params(litellm_params, vector_store_search_optional_params, extra_body)
embedding: Final = self.query_embedding_executor(embedding_executor, None).embed(
params.require_embedding_model(), self.query_text(query), self.query_embedding_configuration(litellm_params)
)
return self._search_request(vector_store_id, embedding, k, params, api_base)
async def atransform_search_vector_store_request(
self,
vector_store_id: str,
query: str | Sequence[str],
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
api_base: str,
litellm_logging_obj: "LiteLLMLoggingObj",
litellm_params: Mapping[str, object],
extra_body: Mapping[str, object] | None = None,
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
) -> tuple[str, dict[str, object]]: # mutable-ok: the provider contract returns a writable JSON request body
params, k = self._params(litellm_params, vector_store_search_optional_params, extra_body)
embedding: Final = await self.query_embedding_executor(embedding_executor, None).aembed(
params.require_embedding_model(), self.query_text(query), self.query_embedding_configuration(litellm_params)
)
return self._search_request(vector_store_id, embedding, k, params, api_base)
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,
) -> tuple[dict[str, str], bytes]: # mutable-ok: the shared HTTP handler requires writable headers
region: Final = self._signing_region(optional_params, api_base)
body: Final = json.dumps(request_data)
signed: Final = sign_aws_json_post(
get_credentials=partial(self._signing_credentials, optional_params, region),
service_name=aws_signing_target(api_base)[0],
aws_region_name=region,
url=api_base,
body=body,
headers={
"Content-Type": "application/json",
"x-amz-content-sha256": hashlib.sha256(body.encode()).hexdigest(),
},
)
signed_headers: Final = dict(signed.headers.items())
signed_names: Final = frozenset(name.lower() for name in signed_headers)
return {
**{name: value for name, value in headers.items() if name.lower() not in signed_names},
**signed_headers,
}, body.encode()
def transform_search_vector_store_response(
self, response: httpx.Response, litellm_logging_obj: "LiteLLMLoggingObj"
) -> VectorStoreSearchResponse:
if not response.is_success:
raise BaseLLMException(status_code=response.status_code, message=response.text, headers=response.headers)
try:
hits: Final = _SearchResponse.model_validate_json(response.content).hits.hits
except ValidationError:
raise BaseLLMException(
status_code=502,
message="OpenSearch answered the search without a hits.hits list.",
headers=response.headers,
) from None
logged: Final = _LoggedSearch.model_validate(litellm_logging_obj.model_call_details)
return VectorStoreSearchResponse(
object="vector_store.search_results.page",
search_query=logged.search_query,
data=[_search_result(hit, logged.litellm_params) for hit in hits],
)
def validate_create_vector_store(self) -> NoReturn:
raise _bad_request(_SEARCH_ONLY_MESSAGE)
def transform_create_vector_store_request(
self, vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, api_base: str
) -> NoReturn:
raise _bad_request(_SEARCH_ONLY_MESSAGE)
def transform_create_vector_store_response(self, response: httpx.Response) -> NoReturn:
raise _bad_request(_SEARCH_ONLY_MESSAGE)

View file

@ -11,6 +11,7 @@ import httpx
from pydantic import BaseModel
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import message_with_file_parts_as_image_urls
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
@ -104,7 +105,7 @@ def _message_role_mapping(role: str) -> type[ChatMessage]:
def _messages_to_sap_template(messages: list[AllMessageValues]) -> list:
template: Final = []
for message in messages:
folded_message = _fold_message_cache_control(message)
folded_message = _fold_message_cache_control(message_with_file_parts_as_image_urls(message))
message_role_class = _message_role_mapping(str(folded_message["role"]))
validated_message = validate_dict(folded_message, message_role_class)
template.append(validated_message)

View file

@ -112,7 +112,7 @@ def _convert_image_url_to_anthropic(block: Mapping[str, object]) -> object:
pdf/text data URIs become document blocks rather than malformed image blocks.
"""
image_url: Final = block.get("image_url")
url: Final = image_url if isinstance(image_url, str) else _image_url_field(image_url, "url")
url: Final = image_url if isinstance(image_url, str) else _str_field(image_url, "url")
if not url:
return block
@ -121,18 +121,31 @@ def _convert_image_url_to_anthropic(block: Mapping[str, object]) -> object:
if select_anthropic_content_block_type_for_file(_data_uri_media_type(url)) == "document"
else create_anthropic_image_param(
image_url if isinstance(image_url, dict) else url,
format=_image_url_field(image_url, "format"),
format=_str_field(image_url, "format"),
is_bedrock_invoke=True,
)
)
return _with_cache_control(converted, block)
def _convert_file_to_anthropic(block: Mapping[str, object]) -> object:
file_data: Final = _str_field(block.get("file"), "file_data")
if not file_data:
return block
return _with_cache_control(
anthropic_process_openai_file_message({"type": "file", "file": {"file_data": file_data}}), block
)
def _with_cache_control(converted: Mapping[str, object], block: Mapping[str, object]) -> Mapping[str, object]:
cache_control: Final = block.get("cache_control")
if cache_control is None:
return converted
return {**converted, "cache_control": cache_control}
def _image_url_field(image_url: object, key: str) -> str | None:
value: Final = image_url.get(key) if isinstance(image_url, dict) else None
def _str_field(container: object, key: str) -> str | None:
value: Final = container.get(key) if isinstance(container, dict) else None
return value if isinstance(value, str) else None
@ -141,15 +154,23 @@ def _data_uri_media_type(url: str) -> str:
return match.group(1) if match else ""
def _convert_image_url_blocks_to_anthropic(content: object) -> object:
def _convert_media_block_to_anthropic(block: object) -> object:
mapping: Final = as_str_mapping(block)
if mapping is None:
return block
match mapping.get("type"):
case "image_url":
return _convert_image_url_to_anthropic(mapping)
case "file":
return _convert_file_to_anthropic(mapping)
case _:
return block
def _convert_media_blocks_to_anthropic(content: object) -> object:
if not isinstance(content, list):
return content
return [
_convert_image_url_to_anthropic(block)
if isinstance(block, Mapping) and block.get("type") == "image_url"
else block
for block in content
]
return [_convert_media_block_to_anthropic(block) for block in content]
def _convert_tool_result_to_anthropic(
@ -391,7 +412,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
conversation.append(
{
"role": role,
"content": _convert_image_url_blocks_to_anthropic(content),
"content": _convert_media_blocks_to_anthropic(content),
}
)

View file

@ -3,7 +3,7 @@ Shared utilities for the Soniox provider (https://soniox.com).
"""
from collections.abc import Mapping, Sequence
from typing import Final, TypeAlias
from typing import Final, Literal, TypeAlias
from litellm.litellm_core_utils.audio_utils.subtitle_utils import (
SubtitleToken,
@ -17,6 +17,8 @@ SonioxToken: TypeAlias = Mapping[str, object]
# Soniox API base URL.
SONIOX_API_BASE: Final[str] = "https://api.soniox.com"
SONIOX_REALTIME_MODEL_PREFIX: Final = "stt-rt-"
# Default polling interval in seconds when waiting for an async transcription
# to finish. Mirrors the Soniox SDK default.
SONIOX_DEFAULT_POLL_INTERVAL: Final[float] = 1.0
@ -54,6 +56,10 @@ class SonioxException(BaseLLMException):
"""Provider-specific exception class for Soniox."""
def soniox_health_check_mode(model: str) -> Literal["realtime"] | None:
return "realtime" if model.startswith(SONIOX_REALTIME_MODEL_PREFIX) else None
def get_soniox_api_key(api_key: str | None = None) -> str | None:
"""Resolve the Soniox API key from arg or env var."""
# Local import to avoid a circular import: litellm.secret_managers.main

View file

View file

@ -0,0 +1,609 @@
import asyncio
import time
from collections import deque
from collections.abc import Awaitable, Callable, Mapping
from itertools import chain, groupby
from types import MappingProxyType, TracebackType
from typing import Final
from urllib.parse import urlparse, urlunparse
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
from typing_extensions import NotRequired, ReadOnly, Required, Self, TypedDict
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.realtime.transcription_protocol import (
SESSION_UPDATE_EVENT_TYPES,
RealtimeTranscriptionProtocolError,
TranscriptionSessionUpdate,
completed_event,
decode_pcm16_append,
delta_event,
duration_usage,
error_event,
json_object,
parse_transcription_session_update,
speech_event,
transcription_session,
transcription_session_created_event,
uses_server_vad,
)
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig, RealtimeBackend
from litellm.llms.custom_httpx.http_handler import realtime_ssl_for_url
from litellm.llms.soniox.common_utils import SonioxException, get_soniox_api_key
from litellm.types.llms.openai import (
OpenAIRealtimeErrorEvent,
OpenAIRealtimeEvents,
OpenAIRealtimeInputAudioTranscriptionCompleted,
OpenAIRealtimeInputAudioTranscriptionDelta,
OpenAIRealtimeTranscriptionSessionCreated,
)
from litellm.types.realtime import (
RealtimeInputAudioTranscriptionUsage,
RealtimeResponseTransformInput,
RealtimeResponseTypedDict,
)
DEFAULT_SONIOX_REALTIME_URL: Final = "wss://stt-rt.soniox.com/transcribe-websocket"
WEBSOCKET_SCHEMES: Final = MappingProxyType({"wss": "wss", "ws": "ws", "https": "wss", "http": "ws"})
DEFAULT_SAMPLE_RATE: Final = 24_000
KEEPALIVE_INTERVAL_SECONDS: Final = 5.0
_MAX_PENDING_FRAMES: Final = 64
_FINALIZE: Final = '{"type":"finalize"}'
_KEEPALIVE: Final = '{"type":"keepalive"}'
_END_STREAM: Final = ""
_HEALTH_CHECK_SILENCE: Final = bytes(DEFAULT_SAMPLE_RATE // 5)
_CONTROL_FRAMES: Final = frozenset((_FINALIZE, _KEEPALIVE, _END_STREAM))
SESSION_STARTED_FRAME: Final = '{"type":"litellm.soniox.session_started"}'
_ENDPOINT_TOKEN: Final = "<end>"
_FINALIZED_TOKEN: Final = "<fin>"
class SonioxProtocolError(RealtimeTranscriptionProtocolError):
pass
class SonioxRealtimeOptions(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
language_hints: tuple[str, ...] | None = None
language_hints_strict: bool | None = None
enable_language_identification: bool | None = None
enable_speaker_diarization: bool | None = None
context: JsonValue | None = None
translation: JsonValue | None = None
client_reference_id: str | None = None
max_endpoint_delay_ms: int | None = None
endpoint_sensitivity: float | None = None
endpoint_latency_adjustment_level: int | None = None
class SonioxStartRequest(SonioxRealtimeOptions):
model: str
audio_format: str
sample_rate: int
num_channels: int
enable_endpoint_detection: bool
class SonioxSpeechLabels(TypedDict, total=False):
speaker: ReadOnly[str]
language: ReadOnly[str]
class SonioxTranscriptSegment(SonioxSpeechLabels):
text: Required[ReadOnly[str]]
class SonioxTranscriptionDelta(OpenAIRealtimeInputAudioTranscriptionDelta, SonioxSpeechLabels):
pass
class SonioxTranscriptionCompleted(OpenAIRealtimeInputAudioTranscriptionCompleted):
segments: NotRequired[ReadOnly[tuple[SonioxTranscriptSegment, ...]]]
class _SonioxToken(BaseModel):
model_config = ConfigDict(frozen=True)
text: str
is_final: bool = False
translation_status: str | None = None
speaker: str | None = None
language: str | None = None
@property
def labelled(self) -> bool:
return self.speaker is not None or self.language is not None
class _SonioxResponse(BaseModel):
model_config = ConfigDict(frozen=True)
tokens: tuple[_SonioxToken, ...] = ()
total_audio_proc_ms: int | None = None
error_code: int | None = None
error_message: str | None = None
finished: bool = False
def _sample_rate(update: TranscriptionSessionUpdate | None) -> int:
audio_format: Final = None if update is None else update.audio_format
if audio_format is None:
return DEFAULT_SAMPLE_RATE
if not audio_format.is_pcm16:
raise SonioxProtocolError("Soniox realtime requires pcm16 input audio")
if audio_format.channels not in (None, 1):
raise SonioxProtocolError("Soniox realtime requires mono input audio")
if audio_format.rate is None:
return DEFAULT_SAMPLE_RATE
if audio_format.rate <= 0:
raise SonioxProtocolError("sample rate must be positive")
return audio_format.rate
def _language_hints(
language: str | None, configured: tuple[str, ...] | None, strict: bool | None
) -> tuple[str, ...] | None:
if not language or (strict and configured):
return configured
return (language, *(hint for hint in configured or () if hint != language))
def _uses_endpoint_detection(update: TranscriptionSessionUpdate, drop_params: bool) -> bool:
return uses_server_vad(update, "Soniox realtime", drop_params, SonioxProtocolError)
def _ignored_settings(update: TranscriptionSessionUpdate) -> tuple[str, ...]:
vad_tuning: Final = (f"turn_detection.{key}" for key in sorted(update.turn_detection or {}) if key != "type")
return (*update.unsupported_transcription_keys, *vad_tuning)
def build_start_request(
model: str, update: TranscriptionSessionUpdate | None, options: SonioxRealtimeOptions, drop_params: bool = False
) -> SonioxStartRequest:
return SonioxStartRequest(
**options.model_dump(exclude={"language_hints"}),
language_hints=_language_hints(
None if update is None else update.language, options.language_hints, options.language_hints_strict
),
model=model,
audio_format="pcm_s16le",
sample_rate=_sample_rate(update),
num_channels=1,
enable_endpoint_detection=update is None or _uses_endpoint_detection(update, drop_params),
)
def _session_created(session_id: str, start_request: SonioxStartRequest) -> OpenAIRealtimeTranscriptionSessionCreated:
session: Final = transcription_session(
session_id=session_id,
model=start_request.model,
sample_rate=start_request.sample_rate,
language=start_request.language_hints[0] if start_request.language_hints else None,
server_vad=start_request.enable_endpoint_detection,
)
return transcription_session_created_event(session)
def _changes_started_rate(update: TranscriptionSessionUpdate, started: SonioxStartRequest) -> bool:
audio_format: Final = update.audio_format
if audio_format is None:
return False
requested_rate: Final = _sample_rate(update)
return audio_format.rate is not None and requested_rate != started.sample_rate
def _changes_started_stream(update: TranscriptionSessionUpdate, started: SonioxStartRequest, drop_params: bool) -> bool:
turn_detection_set: Final = update.turn_detection is not None or update.turn_detection_disabled
return (
_changes_started_rate(update, started)
or (
update.language is not None
and _language_hints(update.language, started.language_hints, started.language_hints_strict)
!= started.language_hints
)
or (turn_detection_set and _uses_endpoint_detection(update, drop_params) != started.enable_endpoint_detection)
)
def build_soniox_realtime_url(api_base: str | None) -> str:
if api_base is None:
return DEFAULT_SONIOX_REALTIME_URL
parsed: Final = urlparse(api_base)
scheme: Final = WEBSOCKET_SCHEMES.get(parsed.scheme)
if scheme is None:
raise ValueError("Soniox realtime api_base must be a wss://, ws://, https://, or http:// URL")
return urlunparse(parsed._replace(scheme=scheme))
def _labels(token: _SonioxToken) -> SonioxSpeechLabels:
speaker: Final[SonioxSpeechLabels] = {} if token.speaker is None else {"speaker": token.speaker}
language: Final[SonioxSpeechLabels] = {} if token.language is None else {"language": token.language}
return {**speaker, **language}
def _delta(item_id: str, token: _SonioxToken) -> SonioxTranscriptionDelta:
return {**delta_event(item_id, token.text), **_labels(token)}
def _segment(run: tuple[_SonioxToken, ...]) -> SonioxTranscriptSegment:
return {**_labels(run[0]), "text": "".join(token.text for token in run).strip()}
def _segments(tokens: tuple[_SonioxToken, ...]) -> tuple[SonioxTranscriptSegment, ...]:
return tuple(_segment(tuple(run)) for _, run in groupby(tokens, key=lambda token: (token.speaker, token.language)))
def _new_item_id() -> str:
return f"item_{uuid.uuid4().hex}"
def _completed(
item_id: str, tokens: tuple[_SonioxToken, ...], usage: RealtimeInputAudioTranscriptionUsage | None
) -> OpenAIRealtimeInputAudioTranscriptionCompleted:
completed: Final = completed_event(item_id, "".join(token.text for token in tokens).strip(), usage)
if not any(token.labelled for token in tokens):
return completed
labelled: Final[SonioxTranscriptionCompleted] = {**completed, "segments": _segments(tokens)}
return labelled
def _soniox_error(response: _SonioxResponse) -> SonioxException | None:
if response.error_code is None and response.error_message is None:
return None
return SonioxException(
status_code=response.error_code or 500,
message=f"Soniox realtime error {response.error_code}: {response.error_message}",
)
class SonioxEventTransformer:
def __init__(self, *, drops_originals: bool) -> None:
self._drops_originals: Final = drops_originals
self._item_id: str | None = None
self._final_tokens: Final[list[_SonioxToken]] = [] # mutable-ok: a turn's tokens append in O(1) each
self._finalizes: Final[deque[bool]] = deque() # mutable-ok: pending finalizes queue and pop in O(1) each
self._oldest_commit_answered: bool = False
self._processed_ms: int = 0
self._billed_ms: int = 0
self._refusal: SonioxException | None = None
@property
def refusal(self) -> SonioxException | None:
return self._refusal
def transform(self, payload: str) -> tuple[OpenAIRealtimeEvents, ...]:
try:
response: Final = _SonioxResponse.model_validate_json(payload)
except ValidationError:
raise SonioxProtocolError("Soniox returned an invalid realtime response") from None
self._bill(response.total_audio_proc_ms)
error: Final = _soniox_error(response)
if error is not None:
return (self._report(error),)
return tuple(chain.from_iterable(self._token_events(token) for token in response.tokens))
def queue_finalize(self, *, discards_turn: bool) -> None:
if discards_turn and not self._finalizes:
self._item_id = None
self._final_tokens.clear()
self._finalizes.append(discards_turn)
def take_unbilled_usage(self, stream_ms: int = 0) -> RealtimeInputAudioTranscriptionUsage | None:
billable_ms: Final = max(self._processed_ms, stream_ms)
unbilled_ms: Final = billable_ms - self._billed_ms
if unbilled_ms <= 0:
return None
self._billed_ms = billable_ms
return duration_usage(unbilled_ms / 1000)
def _report(self, error: SonioxException) -> OpenAIRealtimeErrorEvent:
if self._processed_ms == 0:
self._refusal = error
return error_event(error.message)
def _bill(self, total_audio_proc_ms: int | None) -> None:
if total_audio_proc_ms is not None and total_audio_proc_ms > self._processed_ms:
self._processed_ms = total_audio_proc_ms
def _token_events(self, token: _SonioxToken) -> tuple[OpenAIRealtimeEvents, ...]:
if token.text == _FINALIZED_TOKEN:
return self._finalized()
if self._discarding:
return ()
if token.text == _ENDPOINT_TOKEN:
return self._end_turn()
started: Final = self._start()
if not token.is_final or (self._drops_originals and token.translation_status == "original"):
return started
self._final_tokens.append(token)
return (*started, _delta(self._current_item_id(), token))
@property
def _discarding(self) -> bool:
return bool(self._finalizes) and self._finalizes[0]
def _finalized(self) -> tuple[OpenAIRealtimeEvents, ...]:
discarded: Final = self._finalizes.popleft() if self._finalizes else None
answered: Final = self._oldest_commit_answered
self._oldest_commit_answered = False
if discarded:
return ()
if self._item_id is not None:
return self._complete(speech_stopped=False)
if discarded is None or answered:
return ()
return (_completed(_new_item_id(), (), self.take_unbilled_usage()),)
def _end_turn(self) -> tuple[OpenAIRealtimeEvents, ...]:
events: Final = self._complete(speech_stopped=True)
if events and self._finalizes:
self._oldest_commit_answered = True
return events
def _start(self) -> tuple[OpenAIRealtimeEvents, ...]:
if self._item_id is not None:
return ()
item_id: Final = _new_item_id()
self._item_id = item_id
return (speech_event("input_audio_buffer.speech_started", item_id),)
def _current_item_id(self) -> str:
assert self._item_id is not None
return self._item_id
def _complete(self, *, speech_stopped: bool) -> tuple[OpenAIRealtimeEvents, ...]:
item_id: Final = self._item_id
if item_id is None:
return ()
tokens: Final = tuple(self._final_tokens)
self._item_id = None
self._final_tokens.clear()
stopped: Final[tuple[OpenAIRealtimeEvents, ...]] = (
(speech_event("input_audio_buffer.speech_stopped", item_id),) if speech_stopped else ()
)
return (*stopped, _completed(item_id, tokens, self.take_unbilled_usage()))
class SonioxRealtimeBackend:
def __init__(
self,
inner: RealtimeBackend,
*,
keepalive_interval: float = KEEPALIVE_INTERVAL_SECONDS,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._inner: Final = inner
self._interval: Final = keepalive_interval
self._clock: Final = clock
self._sleep: Final = sleep
self._frames: Final[asyncio.Queue[str | bytes | Exception]] = asyncio.Queue(maxsize=_MAX_PENDING_FRAMES)
self._last_sent: float = clock()
self._started: bool = False
self._ended: bool = False
self._tasks: tuple[asyncio.Task[None], ...] = ()
async def __aenter__(self) -> Self:
await self._inner.__aenter__()
self._tasks = (asyncio.create_task(self._pump()), asyncio.create_task(self._keepalive()))
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self._stop()
await self._inner.__aexit__(exc_type, exc_value, traceback)
async def send(self, message: str | bytes) -> None:
self._last_sent = self._clock()
self._ended = self._ended or message == _END_STREAM
await self._inner.send(message)
if not self._started and isinstance(message, str) and message not in _CONTROL_FRAMES:
self._started = True
await self._frames.put(SESSION_STARTED_FRAME)
async def recv(self, decode: bool | None = None) -> str | bytes:
frame: Final = await self._frames.get()
if isinstance(frame, Exception):
raise frame
return frame
async def close(self) -> None:
self._stop()
await self._inner.close()
def _stop(self) -> None:
for task in self._tasks:
task.cancel()
async def _pump(self) -> None:
while True:
try:
frame = await self._inner.recv()
except Exception as e: # noqa: BLE001 # recv() re-raises it, so an upstream failure never hangs the reader
await self._frames.put(e)
return
await self._frames.put(frame)
async def _keepalive(self) -> None:
while not self._ended:
await self._sleep(self._interval)
if self._ended or self._clock() - self._last_sent < self._interval:
continue
try:
await self.send(_KEEPALIVE)
except Exception as e: # noqa: BLE001 # stops only keepalives; the pump reports the broken socket
verbose_logger.debug("Soniox realtime: stopping keepalive after send failed: %s", e)
return
class SonioxRealtimeConfig(BaseRealtimeConfig):
def __init__(
self,
*,
options: SonioxRealtimeOptions | None = None,
wrap: Callable[[RealtimeBackend], RealtimeBackend] = SonioxRealtimeBackend,
clock: Callable[[], float] = time.monotonic,
drop_params: bool = False,
) -> None:
self._options: Final = options or SonioxRealtimeOptions()
self._drop_params: Final = drop_params
self._wrap: Final = wrap
self._clock: Final = clock
self._transformer: Final = SonioxEventTransformer(drops_originals=self._options.translation is not None)
self._start_request: SonioxStartRequest | None = None
self._session_id: str = f"sess_{uuid.uuid4().hex}"
self._started_at: float | None = None
@classmethod
def from_litellm_params(cls, litellm_params: Mapping[str, object], *, drop_params: bool = False) -> Self:
return cls(options=SonioxRealtimeOptions.model_validate(dict(litellm_params)), drop_params=drop_params)
def requires_session_configuration(self) -> bool:
return True
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseRealtimeConfig contract
model: str,
api_key: str | None = None,
) -> dict[str, str]: # mutable-ok: BaseRealtimeConfig contract
resolved_key: Final = get_soniox_api_key(api_key)
if not resolved_key:
raise ValueError("Missing Soniox API key. Set SONIOX_API_KEY or api_key in the deployment litellm_params")
return {**headers, "Authorization": f"Bearer {resolved_key}"}
def get_complete_url(self, api_base: str | None, model: str, api_key: str | None = None) -> str:
return build_soniox_realtime_url(api_base)
def wrap_backend(self, backend: RealtimeBackend) -> RealtimeBackend:
return self._wrap(backend)
def transform_session_created_event(
self,
model: str,
logging_session_id: str,
session_configuration_request: str | None = None,
) -> OpenAIRealtimeTranscriptionSessionCreated:
self._session_id = logging_session_id
return _session_created(logging_session_id, build_start_request(model, None, self._options))
def transform_realtime_request(
self,
message: str,
model: str,
session_configuration_request: str | None = None,
) -> tuple[str | bytes, ...]:
request: Final = json_object(message, SonioxProtocolError)
event_type: Final = request.get("type")
if event_type is None:
raise SonioxProtocolError("Soniox realtime takes OpenAI realtime events, and this message has no type")
if event_type in SESSION_UPDATE_EVENT_TYPES:
update: Final = parse_transcription_session_update(message, SonioxProtocolError)
ignored: Final = _ignored_settings(update)
if ignored:
verbose_logger.warning("Soniox realtime: ignoring unsupported session settings %s", ignored)
return self._start(model, update)
if event_type == "input_audio_buffer.append":
audio: Final = decode_pcm16_append(request.get("audio"), error=SonioxProtocolError)
return (*self._start(model, None), audio)
if event_type == "input_audio_buffer.commit":
return self._finalize(model, discards_turn=False)
if event_type == "input_audio_buffer.clear":
return () if self._start_request is None else self._finalize(model, discards_turn=True)
if event_type == "input_audio_buffer.end":
return (*self._start(model, None), _END_STREAM)
verbose_logger.debug("Soniox realtime: dropping unsupported client event %s", event_type)
return ()
def _finalize(self, model: str, *, discards_turn: bool) -> tuple[str, ...]:
start: Final = self._start(model, None)
self._transformer.queue_finalize(discards_turn=discards_turn)
return (*start, _FINALIZE)
def session_refusal(self) -> Exception | None:
return self._transformer.refusal
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
started_at: Final = self._started_at
if self._transformer.refusal is not None:
return None
if started_at is None:
return self._transformer.take_unbilled_usage()
return self._transformer.take_unbilled_usage(int((self._clock() - started_at) * 1000))
def transform_realtime_response(
self,
message: str | bytes,
model: str,
logging_obj: LiteLLMLoggingObj,
realtime_response_transform_input: RealtimeResponseTransformInput,
) -> RealtimeResponseTypedDict:
payload: Final = message.decode("utf-8") if isinstance(message, bytes) else message
result: Final[RealtimeResponseTypedDict] = {
"response": list(self._backend_events(payload)),
"current_output_item_id": realtime_response_transform_input.get("current_output_item_id"),
"current_response_id": realtime_response_transform_input.get("current_response_id"),
"current_delta_chunks": realtime_response_transform_input.get("current_delta_chunks"),
"current_conversation_id": realtime_response_transform_input.get("current_conversation_id"),
"current_item_chunks": realtime_response_transform_input.get("current_item_chunks"),
"current_delta_type": realtime_response_transform_input.get("current_delta_type"),
"session_configuration_request": realtime_response_transform_input.get("session_configuration_request"),
}
return result
def _backend_events(self, payload: str) -> tuple[OpenAIRealtimeEvents, ...]:
if payload != SESSION_STARTED_FRAME:
return self._transformer.transform(payload)
assert self._start_request is not None
return (_session_created(self._session_id, self._start_request),)
def _start(self, model: str, update: TranscriptionSessionUpdate | None) -> tuple[str, ...]:
started: Final = self._start_request
if started is not None:
if update is not None and _changes_started_stream(update, started, self._drop_params):
raise SonioxProtocolError("Soniox realtime can't change the session once the stream has started")
return ()
start_request: Final = build_start_request(model, update, self._options, self._drop_params)
self._start_request = start_request
self._started_at = self._clock()
return (start_request.model_dump_json(exclude_none=True),)
def _finishes_stream(frame: str | bytes) -> bool:
response: Final = _SonioxResponse.model_validate_json(frame)
error: Final = _soniox_error(response)
if error is not None:
raise error
return response.finished
async def soniox_realtime_health_check(
model: str, api_base: str | None, api_key: str | None, options: SonioxRealtimeOptions
) -> bool:
import websockets
config: Final = SonioxRealtimeConfig()
url: Final = config.get_complete_url(api_base, model)
headers: Final = config.validate_environment({}, model, api_key)
start_request: Final = build_start_request(model, None, options)
async with websockets.connect(
url,
additional_headers=headers,
max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES,
ssl=realtime_ssl_for_url(url),
) as backend:
await backend.send(start_request.model_dump_json(exclude_none=True))
await backend.send(_HEALTH_CHECK_SILENCE)
await backend.send(_END_STREAM)
async for frame in backend:
if _finishes_stream(frame):
return True
raise SonioxException(status_code=502, message="Soniox closed the realtime stream without finishing it")

View file

@ -9,6 +9,7 @@ import httpx
import litellm
from litellm import LlmProviders
from litellm.litellm_core_utils.prompt_templates.common_utils import messages_with_file_parts_as_image_urls
from litellm.types.llms.vertex_ai import VertexPartnerProvider
from litellm.utils import ModelResponse
@ -217,7 +218,7 @@ class VertexAIPartnerModels(VertexBase):
return base_llm_http_handler.completion(
model=model,
stream=stream,
messages=messages,
messages=list(messages_with_file_parts_as_image_urls(messages)),
acompletion=acompletion,
api_base=api_base,
model_response=model_response,

View file

@ -108,6 +108,10 @@ from litellm.litellm_core_utils.mock_functions import (
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_content_from_model_response as get_content_from_model_response, # noqa: PLC0414 # public re-export
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
messages_have_pdf_file_part,
messages_without_pdf_file_parts,
)
from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_header
from litellm.litellm_core_utils.request_timeout_resolver import (
get_configured_request_timeout,
@ -191,11 +195,13 @@ from litellm.utils import (
get_requester_metadata,
get_secret,
get_standard_openai_params,
is_explicitly_disabled_factory,
mock_completion_streaming_obj,
pre_process_non_default_params,
read_config_args,
should_run_mock_completion,
supports_httpx_timeout,
supports_pdf_input,
validate_and_fix_openai_messages,
validate_and_fix_openai_tools,
validate_and_fix_thinking_param,
@ -393,6 +399,125 @@ def _resolve_control_options(kwargs: Mapping[str, object], model: str) -> Contro
return assert_never(control)
PROVIDERS_WITHOUT_DOCUMENT_INPUT: Final = frozenset({"ollama", "ollama_chat"})
PROVIDERS_DROPPING_UNDECLARED_PDF_INPUT: Final = frozenset({"azure_ai"})
def _cost_map_pdf_input_support(model: str, custom_llm_provider: str) -> bool | None:
if custom_llm_provider in PROVIDERS_WITHOUT_DOCUMENT_INPUT or is_explicitly_disabled_factory(
model, custom_llm_provider, "supports_pdf_input"
):
return False
if supports_pdf_input(model, custom_llm_provider):
return True
return None
def _deployment_pdf_input_support(model: str, base_model: str, custom_llm_provider: str) -> bool | None:
supports: Final = (
_cost_map_pdf_input_support(model, custom_llm_provider),
_cost_map_pdf_input_support(base_model, custom_llm_provider),
)
if False in supports:
return False
if True in supports:
return True
return None
def _declared_pdf_input_support(
model: str, custom_llm_provider: str, model_info: Mapping[str, object] | None, base_model: str | None
) -> bool | None:
match model_info:
case {"supports_pdf_input": bool() as declared}:
return declared
case _ if base_model:
return _deployment_pdf_input_support(model, base_model, custom_llm_provider)
case _:
return _cost_map_pdf_input_support(model, custom_llm_provider)
def _pdf_input_unreadable(
model: str,
custom_llm_provider: str,
model_info: Mapping[str, object] | None,
base_model: str | None,
drop_params: bool,
) -> bool:
match _declared_pdf_input_support(model, custom_llm_provider, model_info, base_model):
case False:
return True
case None:
return drop_params and custom_llm_provider in PROVIDERS_DROPPING_UNDECLARED_PDF_INPUT
case True:
return False
def _messages_without_unreadable_pdfs(
messages: Sequence[AllMessageValues],
model: str,
custom_llm_provider: str,
model_info: Mapping[str, object] | None,
base_model: str | None,
drop_params: bool,
) -> tuple[AllMessageValues, ...] | None:
if not messages_have_pdf_file_part(messages) or not _pdf_input_unreadable(
model, custom_llm_provider, model_info, base_model, drop_params
):
return None
if not drop_params:
raise litellm.BadRequestError(
message=(
f"{custom_llm_provider}/{model} does not accept PDF input. "
"Remove the PDF, or set drop_params=True to send the request without it"
),
model=model,
llm_provider=custom_llm_provider,
)
readable_messages: Final = messages_without_pdf_file_parts(messages)
if not any(message["role"] == "user" for message in readable_messages):
raise litellm.BadRequestError(
message=(
f"{custom_llm_provider}/{model} does not accept PDF input, "
"and no user message is left once the PDF is dropped"
),
model=model,
llm_provider=custom_llm_provider,
)
return readable_messages
def _messages_readable_by_model(
messages: Sequence[AllMessageValues],
model: str,
custom_llm_provider: str,
model_info: Mapping[str, object] | None,
base_model: str | None,
drop_params: bool,
ensure_alternating_roles: bool | None,
user_continue_message: ChatCompletionUserMessage | None,
assistant_continue_message: ChatCompletionAssistantMessage | None,
) -> tuple[AllMessageValues, ...] | None:
pdf_free_messages: Final = _messages_without_unreadable_pdfs(
messages=messages,
model=model,
custom_llm_provider=custom_llm_provider,
model_info=model_info,
base_model=base_model,
drop_params=drop_params,
)
if pdf_free_messages is None:
return None
return tuple(
get_completion_messages(
messages=list(pdf_free_messages),
ensure_alternating_roles=ensure_alternating_roles or False,
user_continue_message=user_continue_message,
assistant_continue_message=assistant_continue_message,
)
)
####### COMPLETION ENDPOINTS ################
@ -4234,7 +4359,6 @@ def _complete_sagemaker(ctx: CompletionDispatchContext) -> _CompletionDispatchRe
_ADDITIONAL_DROP_PARAMS_ADAPTER: Final = TypeAdapter(list[str])
_OPTIONAL_PARAMS_ADAPTER: Final = TypeAdapter(dict[str, object])
_OLLAMA_EMBEDDING_HEADERS_ADAPTER: Final = TypeAdapter(dict[str, str])
_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints)
_BASE_URL_HINT: Final = TypeAdapter(BaseUrlRoutingHint)
@ -5558,9 +5682,15 @@ def completion(
)
headers = kwargs.get("headers", None) or extra_headers
ensure_alternating_roles: Final[bool | None] = kwargs.get("ensure_alternating_roles", None)
user_continue_message: Final[ChatCompletionUserMessage | None] = kwargs.get("user_continue_message", None)
assistant_continue_message: ChatCompletionAssistantMessage | None = kwargs.get("assistant_continue_message", None)
ensure_alternating_roles: Final = cast( # cast-ok: read from untyped kwargs
bool | None, kwargs.get("ensure_alternating_roles", None)
)
user_continue_message: Final = cast( # cast-ok: read from untyped kwargs
ChatCompletionUserMessage | None, kwargs.get("user_continue_message", None)
)
assistant_continue_message: Final = cast( # cast-ok: read from untyped kwargs
ChatCompletionAssistantMessage | None, kwargs.get("assistant_continue_message", None)
)
headers = {} if headers is None else dict(headers)
if extra_headers is not None:
headers.update(extra_headers)
@ -5928,15 +6058,17 @@ def completion(
def optional_params_for_model_info(deployment_info: Mapping[str, object] | None) -> dict[str, object]:
return get_optional_params(**mapping_args, model_info=deployment_info, **mapping_special_params)
optional_params = optional_params_for_model_info(
validated_model_info: Final = (
_MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None
)
deployment_base_model: Final = base_model if isinstance(base_model, str) else None
optional_params = optional_params_for_model_info(validated_model_info)
fakes_stream: Final = partial(
_completion_fakes_stream,
model,
custom_llm_provider,
provider_config,
base_model=base_model if isinstance(base_model, str) else None,
base_model=deployment_base_model,
bridges_to_responses_api=bridges_to_responses_api is True,
)
resolve_timeout: Final = partial(
@ -5974,16 +6106,29 @@ def completion(
functions_unsupported_model: Final[list[object]] = TypeAdapter(list[object]).validate_python(
optional_params.pop("functions_unsupported_model")
)
request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs)
messages = function_call_prompt(
messages=messages,
functions=functions_unsupported_model,
model=model,
llm_provider=custom_llm_provider,
drop_params=litellm.drop_params is True
or normalize_drop_params(request_kwargs.get("drop_params")) is True,
drop_params=drop_unsupported_reasoning,
)
readable_messages: Final = _messages_readable_by_model(
messages=cast(list[AllMessageValues], messages), # cast-ok: completion types messages as a bare List
model=model,
custom_llm_provider=custom_llm_provider,
model_info=validated_model_info,
base_model=deployment_base_model,
drop_params=drop_unsupported_reasoning,
ensure_alternating_roles=ensure_alternating_roles,
user_continue_message=user_continue_message,
assistant_continue_message=assistant_continue_message,
)
messages = ( # rebind-ok: completion() rewrites messages step by step
messages if readable_messages is None else list(readable_messages)
)
# For logging - save the values of the litellm-specific params passed in
requested_litellm_params: Final = get_litellm_params(
acompletion=acompletion,

View file

@ -4049,6 +4049,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true
},
@ -12437,7 +12438,8 @@
"supported_output_modalities": [
"text"
],
"supports_native_streaming": true
"supports_native_streaming": true,
"supports_pdf_input": false
},
"azure_ai/FLUX-1.1-pro": {
"deprecation_date": "2027-01-05",
@ -12502,6 +12504,7 @@
"output_cost_per_token": 1.85e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12516,6 +12519,7 @@
"output_cost_per_token": 3.828e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12531,6 +12535,7 @@
"output_cost_per_token": 3.52e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12546,6 +12551,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12560,6 +12566,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12574,6 +12581,7 @@
"output_cost_per_token": 7.26e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12594,6 +12602,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12616,6 +12625,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12638,6 +12648,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12660,6 +12671,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12687,6 +12699,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12703,6 +12716,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12724,6 +12738,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12744,6 +12759,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12765,6 +12781,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12833,6 +12850,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12847,6 +12865,7 @@
"output_cost_per_token": 7.1e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": {
@ -12859,6 +12878,7 @@
"output_cost_per_token": 1e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12872,6 +12892,7 @@
"output_cost_per_token": 7.8e-07,
"source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12884,6 +12905,7 @@
"mode": "chat",
"output_cost_per_token": 3.7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-70B-Instruct": {
@ -12895,6 +12917,7 @@
"mode": "chat",
"output_cost_per_token": 3.54e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Phi-3-medium-128k-instruct": {
@ -12906,6 +12929,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12918,6 +12942,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12930,6 +12955,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12942,6 +12968,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12954,6 +12981,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12966,6 +12994,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12978,6 +13007,7 @@
"mode": "chat",
"output_cost_per_token": 6.4e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12990,6 +13020,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13002,6 +13033,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13015,6 +13047,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13027,7 +13060,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-multimodal-instruct": {
"deprecation_date": "2027-01-17",
@ -13041,6 +13075,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_audio_input": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_vision": true
},
"azure_ai/Phi-4-mini-reasoning": {
@ -13052,7 +13087,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-reasoning": {
"deprecation_date": "2027-01-17",
@ -13064,6 +13100,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_reasoning": true
},
@ -13106,6 +13143,7 @@
"output_cost_per_token": 1e-05,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/doc-intelligence/prebuilt-read": {
@ -13144,6 +13182,7 @@
"mode": "chat",
"output_cost_per_token": 5.4e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -13202,6 +13241,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13217,6 +13257,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13230,6 +13271,7 @@
"mode": "chat",
"output_cost_per_token": 4.56e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-pro": {
@ -13242,6 +13284,7 @@
"output_cost_per_token": 3.48e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 1.45e-07,
@ -13257,6 +13300,7 @@
"output_cost_per_token": 5.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 2.8e-08,
@ -13273,6 +13317,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13306,6 +13351,7 @@
"output_cost_per_token": 1.5e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13323,6 +13369,7 @@
"output_cost_per_token_above_200k_tokens": 5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13343,6 +13390,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13361,6 +13409,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13379,6 +13428,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13395,6 +13445,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13409,6 +13460,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13424,6 +13476,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13436,7 +13489,8 @@
"max_output_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.00971,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models"
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false
},
"azure_ai/jamba-instruct": {
"deprecation_date": "2025-03-01",
@ -13447,6 +13501,7 @@
"mode": "chat",
"output_cost_per_token": 7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/kimi-k2.5": {
@ -13459,6 +13514,7 @@
"output_cost_per_token": 3e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
@ -13482,6 +13538,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -13498,6 +13555,7 @@
"output_cost_per_token": 4e-08,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.ministral-3b-2410-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large": {
@ -13510,6 +13568,7 @@
"output_cost_per_token": 1.2e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-2407": {
@ -13522,6 +13581,7 @@
"output_cost_per_token": 6e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-latest": {
@ -13533,6 +13593,7 @@
"output_cost_per_token": 6e-06,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-3": {
@ -13544,6 +13605,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13557,6 +13619,7 @@
"output_cost_per_token": 2e-06,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-nemo": {
@ -13568,7 +13631,8 @@
"mode": "chat",
"output_cost_per_token": 1.5e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/mistral-small": {
"deprecation_date": "2025-07-31",
@ -13580,6 +13644,7 @@
"output_cost_per_token": 3e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-small-2503": {
@ -13591,6 +13656,7 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -52006,6 +52072,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52026,6 +52093,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52777,6 +52845,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_web_search": true
},
@ -52794,6 +52863,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -52811,6 +52881,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52989,6 +53060,7 @@
"output_cost_per_token": 3.6e-07,
"output_cost_per_token_batches": 1.8e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true
},
"vertex_ai/openai/gpt-oss-20b-maas": {
@ -53000,6 +53072,7 @@
"mode": "chat",
"output_cost_per_token": 2.5e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true,
"cache_read_input_token_cost": 7e-09,
"input_cost_per_token_batches": 3.5e-08,
@ -53131,6 +53204,7 @@
"us-south1"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": {
@ -53149,6 +53223,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": {
@ -53164,6 +53239,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": {
@ -53179,6 +53255,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/veo-3.1-generate-preview": {
@ -61177,6 +61254,126 @@
],
"supports_audio_input": true
},
"soniox/stt-rt-v5": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-preview": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-preview-v2": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-v3": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-v3-preview": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-v4": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"tensormesh/Qwen/Qwen3.5-397B-A17B-FP8": {
"litellm_provider": "tensormesh",
"mode": "chat",
@ -67202,6 +67399,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67216,6 +67414,7 @@
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67258,6 +67457,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67272,6 +67472,7 @@
"output_cost_per_token": 3.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67286,6 +67487,7 @@
"output_cost_per_token": 5.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67300,6 +67502,7 @@
"output_cost_per_token": 6.25e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67316,6 +67519,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -67331,6 +67535,7 @@
"output_cost_per_token": 3.2e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -67349,6 +67554,7 @@
"supported_output_modalities": [
"text"
],
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_vision": true
},
@ -67358,7 +67564,8 @@
"output_cost_per_token": 5.4e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3-0324": {
"deprecation_date": "2026-07-13",
@ -67366,7 +67573,8 @@
"output_cost_per_token": 4.56e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3.1": {
"deprecation_date": "2026-07-13",
@ -67374,7 +67582,8 @@
"output_cost_per_token": 4.94e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3": {
"deprecation_date": "2026-05-01",
@ -67382,7 +67591,8 @@
"output_cost_per_token": 1.5e-05,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3-mini": {
"deprecation_date": "2026-05-01",
@ -67390,7 +67600,8 @@
"output_cost_per_token": 1.27e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-non-reasoning": {
"deprecation_date": "2026-05-01",
@ -67398,7 +67609,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-reasoning": {
"deprecation_date": "2026-05-01",
@ -67406,7 +67618,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b": {
"input_cost_per_token": 7.2e-08,
@ -73076,6 +73289,7 @@
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supported_regions": ["global"],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -73691,6 +73905,7 @@
"output_cost_per_token": 3.5e-06,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -74745,6 +74960,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -74993,6 +75209,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/developers/models/grok-4.7",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,

View file

@ -3724,7 +3724,7 @@
"ocr": false,
"search": false,
"moderations": false,
"realtime": false,
"realtime": true,
"video_generations": false,
"batches": false,
"messages": false,
@ -4910,6 +4910,33 @@
"vector_stores_search": true
}
},
"opensearch": {
"display_name": "Amazon OpenSearch (`opensearch`)",
"url": "https://docs.litellm.ai/docs/providers/opensearch_vector_stores",
"endpoints": {
"chat_completions": false,
"responses": false,
"text_completion": false,
"embeddings": false,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
"rerank": false,
"ocr": false,
"search": false,
"moderations": false,
"realtime": false,
"video_generations": false,
"batches": false,
"messages": false,
"interactions": false,
"systemone": false,
"a2a": false,
"generateContent": false,
"vector_stores_search": true
}
},
"helicone": {
"display_name": "Helicone (`helicone`)",
"url": "https://docs.litellm.ai/docs/providers/helicone",

View file

@ -130,6 +130,7 @@ from litellm.proxy.guardrails.auto_router_compression import arm_pre_call as _ar
from litellm.proxy.hooks.parallel_request_limiter_v3 import get_or_create_request_stash
from litellm.proxy.native_compaction import with_proxy_compaction_executor
from litellm.proxy.route_llm_request import (
endpoint_unsupported_by_model,
route_request,
)
from litellm.proxy.telemetry.request_context import bind_call_id as bind_telemetry_call_id
@ -872,6 +873,17 @@ _DECODED_JSON: Final = TypeAdapter(object)
_ROUTER_SETTINGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None)
def _fallback_probe_data(
pristine: object, fallback_model: str, key_aliases: object, group_alias: object
) -> Mapping[str, object]:
return {
**_REQUEST_FIELDS.validate_python(pristine),
"model": resolve_served_model(
fallback_model, _DECODED_JSON.validate_python(litellm.model_alias_map), key_aliases, group_alias
),
}
def _request_model(data: Mapping[str, object]) -> str | list[str] | None:
try:
return _REQUEST_MODEL.validate_python(data.get("model"), strict=True)
@ -2679,6 +2691,17 @@ class ProxyBaseLLMRequestProcessing:
original_model,
fallback_models,
)
raw_router_settings: Final = _DECODED_JSON.validate_python(
_REQUEST_FIELDS.validate_python(rate_limited_data).get("router_settings_override")
)
group_alias: Final = _DECODED_JSON.validate_python(
_REQUEST_FIELDS.validate_python(raw_router_settings).get("model_group_alias")
if isinstance(raw_router_settings, Mapping)
else None
)
key_aliases: Final[object] = cast( # cast-ok: auth aliases are dynamically typed at runtime
object, user_api_key_dict.aliases
)
try:
for fallback_model in fallback_models:
if fallback_model == original_model:
@ -2694,6 +2717,13 @@ class ProxyBaseLLMRequestProcessing:
self.data = independent_snapshot(pristine)
self.data["model"] = fallback_model
try:
if endpoint_unsupported_by_model(
route_type,
_fallback_probe_data(pristine, fallback_model, key_aliases, group_alias),
llm_router,
user_api_key_dict.team_id,
):
continue
return await self.common_processing_pre_call_logic(
request=request,
general_settings=general_settings,

View file

@ -436,10 +436,7 @@ def get_model_group_from_litellm_kwargs(kwargs: dict) -> str | None:
def get_model_group_from_request_data(data: dict) -> str | None:
_metadata: Final = data.get("metadata", None) or {}
_model_group: Final = _metadata.get("model_group", None)
if _model_group is not None:
return _model_group
return None
return _model_group if isinstance(_model_group, str) else None
def get_remaining_tokens_and_requests_from_request_data(data: dict) -> dict[str, str]:

View file

@ -647,9 +647,10 @@ class PromptSecurityGuardrail(CustomGuardrail):
async def _process_document_item(self, item: dict, user_api_key_alias: str | None) -> dict:
"""Process and sanitize document/file items."""
doc_data: Final = item.get("document") or item.get("file") or item
url_key: Final = "file_data" if isinstance(doc_data, dict) and not doc_data.get("url") else "url"
if isinstance(doc_data, dict):
url = doc_data.get("url", "")
url = doc_data.get(url_key) or ""
doc_content = doc_data.get("data", "")
else:
url = doc_data if isinstance(doc_data, str) else ""
@ -698,7 +699,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
if url.startswith("data:") and header:
sanitized_url: Final = f"{header},{sanitized_encoded}"
if isinstance(doc_data, dict):
doc_data["url"] = sanitized_url
doc_data[url_key] = sanitized_url
elif isinstance(doc_data, dict):
doc_data["data"] = sanitized_encoded

View file

@ -20,7 +20,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.model_checks import get_all_fallbacks
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.utils import PrismaClient, evict_config_param, invalidate_config_param
from litellm.proxy.utils import PrismaClient, invalidate_config_param
if TYPE_CHECKING:
from fastapi import APIRouter, Depends, HTTPException, status
@ -48,22 +48,50 @@ FallbackRule = dict[str, list[str]]
StoredFallback = Annotated[FallbackRule | dict[str, object] | str, Field(union_mode="left_to_right")]
_STORED_FALLBACKS: Final = TypeAdapter(list[StoredFallback])
_JSON_RULES: Final = TypeAdapter(list[JsonValue])
_STORED_ROUTER_SETTINGS: Final = TypeAdapter(dict[str, JsonValue])
def _rule_covers(entry: StoredFallback, model: str) -> bool:
return isinstance(entry, dict) and model in entry
async def _router_settings_fresh_from_db(proxy_config: "ProxyConfig") -> dict[str, object]:
await evict_config_param(ROUTER_SETTINGS_PARAM)
config: Final = await proxy_config.get_config()
return config.get(ROUTER_SETTINGS_PARAM, {})
async def _reload_config_file_ownership(proxy_config: "ProxyConfig") -> None:
await proxy_config.get_config()
def _reject_config_owned_rules(proxy_config: "ProxyConfig", fallback_key: str, rules: Sequence[StoredFallback]) -> None:
async def _stored_router_settings(prisma_client: PrismaClient) -> Mapping[str, JsonValue]:
try:
row: Final = await ConfigRepository(prisma_client, use_writer=True).get_param(ROUTER_SETTINGS_PARAM)
except json.JSONDecodeError:
return {}
if row is None or not isinstance(row.param_value, Mapping):
return {}
return _STORED_ROUTER_SETTINGS.validate_python(row.param_value)
def _editable_rules(
proxy_config: "ProxyConfig", stored_settings: Mapping[str, JsonValue], fallback_key: str
) -> list[StoredFallback]:
file_settings: Final = proxy_config.router_settings
if not file_settings.owned_by_config(fallback_key):
return _STORED_FALLBACKS.validate_python(stored_settings.get(fallback_key) or [])
file_rules: Final = file_settings.get(fallback_key)
return _STORED_FALLBACKS.validate_python(file_rules if isinstance(file_rules, list) else [])
async def _write_rules(
prisma_client: PrismaClient,
proxy_config: "ProxyConfig",
stored_settings: Mapping[str, JsonValue],
fallback_key: str,
rules: Sequence[StoredFallback],
) -> None:
proxy_config.reject_config_owned_writes(
section_name=ROUTER_SETTINGS_PARAM, changed_keys={fallback_key: _JSON_RULES.validate_python(rules)}
)
if proxy_config.router_settings.owned_by_config(fallback_key):
return
await _persist_router_settings(prisma_client, {**stored_settings, fallback_key: rules})
async def _persist_router_settings(prisma_client: PrismaClient, router_settings: Mapping[str, object]) -> None:
@ -162,7 +190,8 @@ async def create_fallback(
},
)
router_settings: Final = await _router_settings_fresh_from_db(proxy_config)
await _reload_config_file_ownership(proxy_config)
router_settings: Final = await _stored_router_settings(prisma_client)
# Get the appropriate fallback list based on type
fallback_key = "fallbacks"
@ -172,7 +201,7 @@ async def create_fallback(
fallback_key = "content_policy_fallbacks"
# Get existing fallbacks
existing_fallbacks: Final = _STORED_FALLBACKS.validate_python(router_settings.get(fallback_key) or [])
existing_fallbacks: Final = _editable_rules(proxy_config, router_settings, fallback_key)
# Update or add the fallback configuration
fallback_updated = False
@ -187,12 +216,7 @@ async def create_fallback(
# Add new fallback
existing_fallbacks.append({data.model: data.fallback_models})
_reject_config_owned_rules(proxy_config, fallback_key, existing_fallbacks)
# Update router settings
router_settings[fallback_key] = existing_fallbacks
await _persist_router_settings(prisma_client, router_settings)
await _write_rules(prisma_client, proxy_config, router_settings, fallback_key, existing_fallbacks)
# Update the in-memory router configuration
setattr(llm_router, fallback_key, existing_fallbacks)
@ -320,7 +344,8 @@ async def delete_fallback(
},
)
router_settings: Final = await _router_settings_fresh_from_db(proxy_config)
await _reload_config_file_ownership(proxy_config)
router_settings: Final = await _stored_router_settings(prisma_client)
# Get the appropriate fallback list based on type
fallback_key = "fallbacks"
@ -330,7 +355,7 @@ async def delete_fallback(
fallback_key = "content_policy_fallbacks"
# Get existing fallbacks
existing_fallbacks: Final = _STORED_FALLBACKS.validate_python(router_settings.get(fallback_key) or [])
existing_fallbacks: Final = _editable_rules(proxy_config, router_settings, fallback_key)
updated_fallbacks: Final = [rule for rule in existing_fallbacks if not _rule_covers(rule, model)]
@ -340,12 +365,7 @@ async def delete_fallback(
detail={"error": f"No {fallback_type} fallbacks configured for model '{model}'"},
)
_reject_config_owned_rules(proxy_config, fallback_key, updated_fallbacks)
# Update router settings
router_settings[fallback_key] = updated_fallbacks
await _persist_router_settings(prisma_client, router_settings)
await _write_rules(prisma_client, proxy_config, router_settings, fallback_key, updated_fallbacks)
# Update the in-memory router configuration
setattr(llm_router, fallback_key, updated_fallbacks)

View file

@ -3612,7 +3612,7 @@
"key": "api_key",
"label": "Soniox API Key",
"placeholder": null,
"tooltip": "Currently only the async Speech-to-Text REST API (api.soniox.com) is supported. Realtime STT (stt-rt.soniox.com) and TTS (tts-rt.soniox.com) are not yet available.",
"tooltip": "Supports async Speech-to-Text (api.soniox.com) on /v1/audio/transcriptions and realtime Speech-to-Text (stt-rt.soniox.com) on /v1/realtime. TTS (tts-rt.soniox.com) is not yet available.",
"required": true,
"field_type": "password",
"options": null,

View file

@ -54,6 +54,7 @@ from litellm.proxy.rag_endpoints.upload_security import (
from litellm.proxy.vector_store_endpoints.endpoints import (
build_request_data_from_managed_vector_store,
reject_caller_embedding_selection_params,
reject_caller_opensearch_index_name,
)
from litellm.proxy.vector_store_endpoints.utils import (
assert_user_can_access_vector_store_id,
@ -913,6 +914,11 @@ async def rag_query(
**retrieval_config,
**store_data,
}
reject_caller_opensearch_index_name(
caller_body=retrieval_config,
request_fields=merged_retrieval_config,
source="retrieval_config",
)
effective_vector_store_config: Final = (
managed_store_params
if managed_store is not None

View file

@ -34,6 +34,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.anthropic_interface.exceptions.exception_mapping_utils import AnthropicExceptionMapping
from litellm.caching.dual_cache import DualCache
from litellm.constants import EMPTY_MAPPING, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY, TERMINAL_RESPONSE_STATUSES
from litellm.exceptions import EndpointNotSupportedByModelError
from litellm.integrations.custom_guardrail import ModifyResponseException
from litellm.litellm_core_utils.core_helpers import as_str_mapping, get_metadata_variable_name_from_kwargs
from litellm.litellm_core_utils.internal_call_metadata import is_background_response, requests_background
@ -2086,6 +2087,14 @@ def _routing_hints_from_first_ws_frame(first_message: str) -> Mapping[str, objec
def _responses_ws_failure_frame(failure: Exception) -> str:
if isinstance(failure, EndpointNotSupportedByModelError):
return json.dumps(
{
"type": "error",
"status": 400,
"error": {"type": "invalid_request_error", "message": failure.message.split("\n\n", 1)[0]},
}
)
proxy_exception: Final = handle_exception_on_proxy(failure)
status: Final = int(proxy_exception.code) if str(proxy_exception.code).isdigit() else 500
error_type: Final = (
@ -2444,7 +2453,12 @@ async def _responses_websocket_session(
with contextlib.suppress(Exception):
await websocket.send_text(_responses_ws_failure_frame(e))
with contextlib.suppress(Exception):
await websocket.close(code=1011, reason="Internal server error")
await websocket.close(
code=1008 if isinstance(e, EndpointNotSupportedByModelError) else 1011,
reason="Pre-call error"
if isinstance(e, EndpointNotSupportedByModelError)
else "Internal server error",
)
return crash_row
return bill_socket_failure(outcome) if isinstance(outcome, Exception) else None

View file

@ -431,6 +431,28 @@ def raise_if_required_body_param_missing(
)
def endpoint_unsupported_by_model(
route_type: str,
data: Mapping[str, object],
llm_router: LitellmRouter | None,
team_id: str | None,
) -> bool:
model: Final = data.get("model")
if not isinstance(model, str) or llm_router is None:
return False
metadata: Final = data.get("metadata")
request_kwargs: Final = {
**data,
"metadata": {
**(metadata if isinstance(metadata, Mapping) else {}),
"user_api_key_team_id": team_id,
},
}
return bool(llm_router.get_routable_deployments(model, request_kwargs)) and not llm_router.get_routable_deployments(
model, request_kwargs, call_type=route_type
)
class MockTestingParamsDisabledError(HTTPException):
def __init__(self, params: tuple[str, ...]):
super().__init__(

View file

@ -464,7 +464,11 @@ def _sl_attribution_fallback(
) -> str:
if standard_logging_payload is None:
return ""
return standard_logging_payload.get(field) or ""
return _string_or_empty(standard_logging_payload.get(field))
def _string_or_empty(value: object) -> str:
return value if isinstance(value, str) else ""
def _deployment_provider(deployment: DeploymentTypedDict) -> str | None:
@ -682,7 +686,7 @@ def get_logging_payload(
_model_id: Final = metadata.get("model_info", {}).get("id", "") or _sl_attribution_fallback(
standard_logging_payload, "model_id"
)
_model_group: Final = metadata.get("model_group", "") or _sl_attribution_fallback(
_model_group: Final = _string_or_empty(metadata.get("model_group")) or _sl_attribution_fallback(
standard_logging_payload, "model_group"
)
_api_base: Final = litellm_params.get("api_base", "") or _sl_attribution_fallback(

View file

@ -17,6 +17,7 @@ from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.common_utils.http_parsing_utils import read_raw_json_body
from litellm.proxy.route_llm_request import JSON_OBJECT_ADAPTER
from litellm.proxy.utils import jsonify_object
from litellm.proxy.vector_store_endpoints.utils import (
assert_proxy_admin_for_vector_store_index_management,
@ -24,6 +25,7 @@ from litellm.proxy.vector_store_endpoints.utils import (
get_litellm_managed_vector_store,
)
from litellm.repositories.table_repositories import ManagedVectorStoreIndexRepository
from litellm.types.utils import LlmProviders
from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse
from litellm.vector_stores.utils import VectorStoreWriteBody, callers_metadata_snapshot
from litellm.vector_stores.vector_store_registry import VectorStoreIndexRegistry
@ -52,6 +54,24 @@ def reject_caller_embedding_selection_params(payload: Mapping[str, object], sour
)
def reject_caller_opensearch_index_name(
caller_body: Mapping[str, object], request_fields: Mapping[str, object], source: str
) -> None:
provider: Final = request_fields.get("custom_llm_provider")
if (
isinstance(provider, str)
and provider.split("/", 1)[0] == LlmProviders.OPENSEARCH.value
and "opensearch_index_name" in caller_body
):
raise HTTPException(
status_code=400,
detail={
"error": f"'opensearch_index_name' cannot be set in {source}. "
"The index comes from the vector store's server-side registration."
},
)
########################################################
# OpenAI Compatible Endpoints
########################################################
@ -138,11 +158,17 @@ async def vector_store_search(
data = await read_request_body(request=request) # rebind-ok: pre-existing rebinding on a rename-only line
reject_caller_embedding_selection_params(payload=data, source="the search request body")
data["vector_store_id"] = vector_store_id
caller_body: Final = JSON_OBJECT_ADAPTER.validate_python(data)
# Check for legacy vector store registry (non-managed vector stores)
data = await _update_request_data_with_litellm_managed_vector_store_registry(
data=data, vector_store_id=vector_store_id, user_api_key_dict=user_api_key_dict
)
reject_caller_opensearch_index_name(
caller_body=caller_body,
request_fields=JSON_OBJECT_ADAPTER.validate_python(data),
source="the search request body",
)
# The managed_vector_stores pre-call hook will handle:
# 1. Decoding managed vector store IDs

View file

@ -4,6 +4,6 @@ Supported endpoints:
- WebSocket: `/v1/realtime` (with `intent=transcription` for transcription-only sessions)
- HTTP: `/v1/realtime/client_secrets`, `/v1/realtime/transcription_sessions`
Supported providers: OpenAI, Azure OpenAI, Bedrock, Vertex AI, xAI.
Supported providers: OpenAI, Azure OpenAI, Bedrock, Vertex AI, xAI, Gemini, Meta, Soniox.
For user-facing documentation and usage examples, see the litellm-docs repo.

View file

@ -7,6 +7,7 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, cast
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
@ -55,6 +56,7 @@ from ..llms.openai.realtime.handler import (
confirm_session_started,
)
from ..llms.openai.workload_identity import openai_static_api_key, resolve_openai_bearer_token_async
from ..llms.soniox.realtime.transformation import SonioxRealtimeOptions, soniox_realtime_health_check
from ..llms.vertex_ai.audio_transcription.realtime_transformation import is_vertex_speech_to_text_model
from ..llms.vertex_ai.realtime.transformation import VertexAIRealtimeConfig, vertex_realtime_config
from ..llms.vertex_ai.vertex_llm_base import VertexBase
@ -75,6 +77,7 @@ vertex_llm_base: Final = VertexBase()
base_llm_http_handler = BaseLLMHTTPHandler()
_EMPTY_MODEL_PARAMS: Final[Mapping[str, object]] = MappingProxyType({})
_EMPTY_AUTH_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
_DEPLOYMENT_PARAMS: Final = TypeAdapter(Mapping[str, object])
def _model_params_with_stored_credentials(model_params: Mapping[str, object]) -> Mapping[str, object]:
@ -527,6 +530,7 @@ async def _arealtime(
custom_llm_provider=_custom_llm_provider,
)
deployment_params: Final = _DEPLOYMENT_PARAMS.validate_python(kwargs)
if live_session_start is not None:
await _alive_session(
model=model,
@ -537,7 +541,7 @@ async def _arealtime(
api_base=dynamic_api_base or litellm_params.api_base,
api_key=dynamic_api_key,
litellm_metadata=_build_litellm_metadata(kwargs),
litellm_params=kwargs,
litellm_params=deployment_params,
)
return
@ -548,6 +552,7 @@ async def _arealtime(
model=model,
provider=LlmProviders(_custom_llm_provider),
drop_params=drop_params,
litellm_params=deployment_params,
)
if provider_config is not None:
await base_llm_http_handler.async_realtime(
@ -882,6 +887,13 @@ async def realtime_health_check(
)
elif custom_llm_provider == "openai":
url = openai_realtime.construct_url(api_base=openai_api_base, query_params={"model": model})
elif custom_llm_provider == "soniox":
return await soniox_realtime_health_check(
model=model,
api_base=resolved_api_base,
api_key=health_check_api_key,
options=SonioxRealtimeOptions.model_validate(dict(resolved_params)),
)
elif custom_llm_provider == "xai":
url = xai_realtime.construct_url(
api_base=resolved_api_base or "https://api.x.ai/v1", query_params={"model": model}

View file

@ -287,6 +287,11 @@ from litellm.router_utils.embedding_batch_size_check import (
embedding_deployment_selection,
log_rejection_before_provider_call,
)
from litellm.router_utils.endpoint_eligibility import (
eligible_deployments,
endpoint_selection,
warn_on_unenforced_endpoint_flag,
)
from litellm.router_utils.fallback_event_handlers import (
MID_STREAM_FALLBACK_CONTROLS_KEY,
AttemptedFallbackTargets,
@ -1449,7 +1454,7 @@ class Router:
self.assistants_config = assistants_config
self.search_tools = search_tools or []
self.guardrail_list = guardrail_list or []
self.deployment_names: list = [] # names of models under litellm_params. ex. azure/chatgpt-v-2
self.deployment_names: list[str] = [] # names of models under litellm_params. ex. azure/chatgpt-v-2
self.deployment_latency_map = {}
### CACHING ###
cache_type: Literal["local", "redis", "redis-semantic", "s3", "disk"] = "local" # default to an in-memory cache
@ -6054,12 +6059,13 @@ class Router:
kwargs.setdefault("metadata", {}).update({"model_group": model})
# pick the one that is available (lowest TPM/RPM)
deployment: Final = self.get_available_deployment(
model=model,
messages=messages,
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
with endpoint_selection("text_completion", model=model):
deployment: Final = self.get_available_deployment(
model=model,
messages=messages,
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
data: Final = deployment["litellm_params"].copy()
for k, v in self.default_litellm_params.items():
@ -6915,6 +6921,7 @@ class Router:
"""
model_group: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
retry_kwargs: Final = mid_stream_retry_kwargs(initial_kwargs)
handler: Final = retry_kwargs.get("original_generic_function")
healthy_deployments, all_deployments = await self._async_get_healthy_deployments(
model=model_group,
parent_otel_span=get_parent_otel_span_from_kwargs(retry_kwargs),
@ -6945,7 +6952,10 @@ class Router:
)
carry_superseded_attempt_usage(retry_kwargs)
try:
response = await attempt_function(**retry_kwargs)
with endpoint_selection(
getattr(handler, "__name__", None), getattr(handler, "__module__", None), model=model_group
):
response = await attempt_function(**retry_kwargs)
except Exception as retry_error: # noqa: BLE001 # the fallback chain judges the failed retry
wrapped = _stream_fallback_error_for_raised(retry_error, model_group, False, e.llm_provider)
if wrapped is None:
@ -9315,9 +9325,18 @@ class Router:
Handler for making a call to the .completion()/.embeddings()/etc. functions.
"""
model_group: Final = kwargs.get("model")
response = original_function(*args, **kwargs)
if coroutine_checker.is_async_callable(response) or inspect.isawaitable(response):
response = await response
generic_function: Final = _OPAQUE_VALUE_ADAPTER.validate_python(kwargs.get("original_generic_function"))
endpoint_function: Final = (
generic_function if callable(generic_function) else _OPAQUE_VALUE_ADAPTER.validate_python(original_function)
)
with endpoint_selection(
getattr(endpoint_function, "__name__", None),
getattr(endpoint_function, "__module__", None),
model=_OPAQUE_VALUE_ADAPTER.validate_python(model_group),
):
response = original_function(*args, **kwargs)
if coroutine_checker.is_async_callable(response) or inspect.isawaitable(response):
response = await response
await self.increment_deployment_usage_for_response(response=response, request_kwargs=kwargs)
## PROCESS RESPONSE HEADERS
response = await self.set_response_headers(response=response, model_group=model_group, request_kwargs=kwargs)
@ -10638,6 +10657,7 @@ class Router:
declared_params: Final = _LITELLM_PARAMS_ADAPTER.validate_python(_litellm_params)
warn_on_provider_credential_mismatch(model_name=_model_name, litellm_params=declared_params)
warn_on_ignored_model_capability_flags(model_name=_model_name, litellm_params=declared_params)
warn_on_unenforced_endpoint_flag(model_name=_model_name, model_info=_model_info)
deployment = without_model_capability_flags(
Deployment(
**deployment_info,
@ -11441,6 +11461,9 @@ class Router:
declared_params: Final = deployment.litellm_params.model_dump(exclude_none=True)
warn_on_provider_credential_mismatch(model_name=deployment.model_name, litellm_params=declared_params)
warn_on_ignored_model_capability_flags(model_name=deployment.model_name, litellm_params=declared_params)
warn_on_unenforced_endpoint_flag(
model_name=deployment.model_name, model_info=deployment.model_info.model_dump()
)
served: Final = with_admin_api_key_secret_reference(without_model_capability_flags(deployment))
# add to model list
@ -14797,9 +14820,15 @@ class Router:
"""
return [m for m in self.model_list if m["litellm_params"]["model"] == model]
def get_routable_deployments(self, model: str) -> tuple[DeploymentTypedDict, ...]:
def get_routable_deployments(
self,
model: str,
request_kwargs: Mapping[str, object] = MappingProxyType({}),
call_type: str | None = None,
) -> tuple[DeploymentTypedDict, ...]:
try:
_, candidates = self._common_checks_available_deployment(model=model, request_kwargs={}) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # its signature leaves input and the result untyped
with endpoint_selection(call_type, model=model):
_, candidates = self._common_checks_available_deployment(model=model, request_kwargs=request_kwargs) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # its signature leaves input and the result untyped
except (litellm.BadRequestError, ValueError):
return ()
if isinstance(candidates, dict):
@ -15075,7 +15104,10 @@ class Router:
model=model,
llm_provider="",
)
return selectable
eligible: Final = eligible_deployments(model, selectable)
if isinstance(eligible, tuple):
return list(eligible)
raise eligible
def _filter_reserved_deployments(
self,

View file

@ -0,0 +1,120 @@
from collections.abc import Generator, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from types import MappingProxyType
from typing import Final, TypeVar
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, field_validator
from litellm._logging import verbose_router_logger
from litellm.exceptions import EndpointNotSupportedByModelError
CHAT_ENDPOINT_BY_CALL: Final[Mapping[str, str]] = MappingProxyType(
{
"completion": "/v1/chat/completions",
"acompletion": "/v1/chat/completions",
"_completion": "/v1/chat/completions",
"_acompletion": "/v1/chat/completions",
"text_completion": "/v1/completions",
"atext_completion": "/v1/completions",
"_atext_completion": "/v1/completions",
"anthropic_messages": "/v1/messages",
"aresponses": "/v1/responses",
"agenerate_content": "generateContent",
"agenerate_content_stream": "streamGenerateContent",
"acreate_interaction": "/v1beta/interactions",
"litellm.interactions.main.acreate": "/v1beta/interactions",
"litellm.interactions.main.create": "/v1beta/interactions",
"_aresponses_websocket": "/v1/responses",
"acompact_responses": "/v1/responses/compact",
}
)
def _endpoint_path(endpoint: str) -> str:
rooted: Final = "/" + endpoint.strip().strip("/").casefold()
path: Final = rooted.removeprefix("/openai").removeprefix("/v1beta").removeprefix("/v1")
return "/" + path.rsplit(":", 1)[-1] if ":" in path else path
CHAT_ENDPOINTS: Final = frozenset(_endpoint_path(endpoint) for endpoint in CHAT_ENDPOINT_BY_CALL.values())
_Deployment: Final = TypeVar("_Deployment", bound=Mapping[str, object])
_ENFORCE_FLAG: Final = TypeAdapter(bool | None)
_REQUEST_SELECTION: Final[ContextVar[tuple[str | None, str | None]]] = ContextVar(
"router_request_endpoint", default=(None, None)
)
class _EndpointDeclaration(BaseModel):
model_config = ConfigDict(frozen=True)
enforce_supported_endpoints: bool | None = None
supported_endpoints: tuple[str, ...] | None = None
@field_validator("supported_endpoints", mode="before")
@classmethod
def _one_endpoint_as_a_list(cls, value: object) -> object:
return (value,) if isinstance(value, str) else value
@contextmanager
def endpoint_selection(call_type: object, module: object = None, model: object = None) -> Generator[None]:
qualified_call: Final = f"{module}.{call_type}" if module == "litellm.interactions.main" else call_type
endpoint: Final = CHAT_ENDPOINT_BY_CALL.get(qualified_call) if isinstance(qualified_call, str) else None
token: Final = _REQUEST_SELECTION.set((endpoint, model if isinstance(model, str) else None))
try:
yield
finally:
_REQUEST_SELECTION.reset(token)
def _enforced_endpoints(model_info: object) -> tuple[str, ...] | None:
try:
declaration: Final = _EndpointDeclaration.model_validate(model_info)
except ValidationError:
return None
return declaration.supported_endpoints if declaration.enforce_supported_endpoints else None
def warn_on_unenforced_endpoint_flag(model_name: str, model_info: Mapping[str, object]) -> str | None:
try:
flagged: Final = _ENFORCE_FLAG.validate_python(model_info.get("enforce_supported_endpoints"))
except ValidationError:
return None
if not flagged or _enforced_endpoints(model_info) is not None:
return None
warning: Final = (
f"Deployment '{model_name}' sets enforce_supported_endpoints without a supported_endpoints list of "
'strings, so litellm enforces nothing for it. Set supported_endpoints, e.g. ["/v1/embeddings"].'
)
verbose_router_logger.warning(warning)
return warning
def non_chat_endpoints(deployment: Mapping[str, object]) -> frozenset[str] | None:
declared: Final = _enforced_endpoints(deployment.get("model_info"))
if declared is None:
return None
endpoints: Final = frozenset(declared)
normalized: Final = frozenset(_endpoint_path(endpoint) for endpoint in endpoints)
return endpoints if normalized.isdisjoint(CHAT_ENDPOINTS) else None
def eligible_deployments(
model: str, deployments: Sequence[_Deployment]
) -> tuple[_Deployment, ...] | EndpointNotSupportedByModelError:
endpoint, requested_model = _REQUEST_SELECTION.get()
if endpoint is None or not deployments:
return tuple(deployments)
eligible: Final = tuple(deployment for deployment in deployments if non_chat_endpoints(deployment) is None)
if eligible:
return eligible
requested: Final = requested_model or model
supported: Final = ", ".join(
sorted(frozenset[str]().union(*(non_chat_endpoints(deployment) or frozenset() for deployment in deployments)))
)
return EndpointNotSupportedByModelError(
message=f"{endpoint} is not supported by model={requested}. Supported endpoints: {supported or 'none'}",
model=model,
llm_provider="",
)

View file

@ -14,6 +14,7 @@ class ForkedAfterNativeRuntimeStarted(RuntimeError): ...
class ProcessReservedForForking(RuntimeError): ...
def trace_encode_error(message: str) -> bytes: ...
def bundled_model_cost_map() -> bytes: ...
@final
class NativeClickHouseSpendConfig:
@ -220,6 +221,7 @@ __all__ = [
"aocr",
"aresponses",
"atranscription",
"bundled_model_cost_map",
"completion",
"embedding",
"gil_stats",

View file

@ -270,6 +270,7 @@ def _whole_float_as_int(value: object) -> object:
class ModelInfo(MirroredPricingParams):
enforce_supported_endpoints: bool | None = None
id: str | None # Allow id to be optional on input, but it will always be present as a str in the model instance
db_model: bool = False # used for proxy - to separate models which are stored in the db vs. config.
updated_at: datetime.datetime | None = None

View file

@ -4170,7 +4170,12 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"})
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
{"supports_regex_lookaround", "supports_assistant_prefill", "supports_bedrock_native_structured_output"}
{
"supports_regex_lookaround",
"supports_assistant_prefill",
"supports_bedrock_native_structured_output",
"supports_pdf_input",
}
)
DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS: Final[frozenset[str]] = frozenset({"token_count_framing", "token_count_model"})
@ -4582,6 +4587,7 @@ class LlmProviders(str, Enum):
S3_VECTORS = "s3_vectors"
VALKEY = "valkey"
MONGODB = "mongodb"
OPENSEARCH = "opensearch"
HELICONE = "helicone"
HYPERBOLIC = "hyperbolic"
RECRAFT = "recraft"

View file

@ -10460,6 +10460,22 @@ class ProviderConfigManager:
return BedrockVectorStore.get_initialized_custom_logger()
return None
@staticmethod
def _get_vertex_ai_vector_stores_config(api_type: str | None) -> BaseVectorStoreConfig | None:
if api_type == "rag_api" or api_type is None:
from litellm.llms.vertex_ai.vector_stores.rag_api.transformation import (
VertexVectorStoreConfig,
)
return VertexVectorStoreConfig()
if api_type == "search_api":
from litellm.llms.vertex_ai.vector_stores.search_api.transformation import (
VertexSearchAPIVectorStoreConfig,
)
return VertexSearchAPIVectorStoreConfig()
return None
@staticmethod
def get_provider_vector_stores_config(
provider: LlmProviders,
@ -10481,18 +10497,7 @@ class ProviderConfigManager:
return AzureOpenAIVectorStoreConfig()
elif litellm.LlmProviders.VERTEX_AI == provider:
if api_type == "rag_api" or api_type is None: # default to rag_api
from litellm.llms.vertex_ai.vector_stores.rag_api.transformation import (
VertexVectorStoreConfig,
)
return VertexVectorStoreConfig()
elif api_type == "search_api":
from litellm.llms.vertex_ai.vector_stores.search_api.transformation import (
VertexSearchAPIVectorStoreConfig,
)
return VertexSearchAPIVectorStoreConfig()
return ProviderConfigManager._get_vertex_ai_vector_stores_config(api_type)
elif litellm.LlmProviders.BEDROCK == provider:
from litellm.llms.bedrock.vector_stores.transformation import (
BedrockVectorStoreConfig,
@ -10547,6 +10552,12 @@ class ProviderConfigManager:
)
return MongoDBVectorStoreConfig()
elif litellm.LlmProviders.OPENSEARCH == provider:
from litellm.llms.opensearch.vector_stores.transformation import (
OpenSearchVectorStoreConfig,
)
return OpenSearchVectorStoreConfig()
return None
@staticmethod
@ -10759,6 +10770,7 @@ class ProviderConfigManager:
model: str,
provider: LlmProviders,
drop_params: bool = False,
litellm_params: Mapping[str, object] = MappingProxyType({}),
) -> BaseRealtimeConfig | None:
if LlmProviders.GEMINI == provider:
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
@ -10768,6 +10780,10 @@ class ProviderConfigManager:
from litellm.llms.meta.realtime.transformation import MetaRealtimeConfig
return MetaRealtimeConfig(drop_params=drop_params)
if LlmProviders.SONIOX == provider:
from litellm.llms.soniox.realtime.transformation import SonioxRealtimeConfig
return SonioxRealtimeConfig.from_litellm_params(litellm_params, drop_params=drop_params)
return None
@staticmethod

View file

@ -43,6 +43,7 @@ ENV UV_PYTHON_DOWNLOADS=0
COPY pyproject.toml README.md rust-toolchain.toml ./
COPY .cargo ./.cargo
COPY litellm-rust ./litellm-rust
COPY model_prices_and_context_window.json ./
RUN --mount=type=cache,target=/root/.cache/uv \
mkdir -p litellm/rust_bridge /wheels && \

View file

@ -4049,6 +4049,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true
},
@ -12437,7 +12438,8 @@
"supported_output_modalities": [
"text"
],
"supports_native_streaming": true
"supports_native_streaming": true,
"supports_pdf_input": false
},
"azure_ai/FLUX-1.1-pro": {
"deprecation_date": "2027-01-05",
@ -12502,6 +12504,7 @@
"output_cost_per_token": 1.85e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12516,6 +12519,7 @@
"output_cost_per_token": 3.828e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12531,6 +12535,7 @@
"output_cost_per_token": 3.52e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12546,6 +12551,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12560,6 +12566,7 @@
"output_cost_per_token": 4.84e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12574,6 +12581,7 @@
"output_cost_per_token": 7.26e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12594,6 +12602,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12616,6 +12625,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12638,6 +12648,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12660,6 +12671,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12687,6 +12699,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12703,6 +12716,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12724,6 +12738,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12744,6 +12759,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -12765,6 +12781,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12833,6 +12850,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -12847,6 +12865,7 @@
"output_cost_per_token": 7.1e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": {
@ -12859,6 +12878,7 @@
"output_cost_per_token": 1e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12872,6 +12892,7 @@
"output_cost_per_token": 7.8e-07,
"source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -12884,6 +12905,7 @@
"mode": "chat",
"output_cost_per_token": 3.7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-70B-Instruct": {
@ -12895,6 +12917,7 @@
"mode": "chat",
"output_cost_per_token": 3.54e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/Phi-3-medium-128k-instruct": {
@ -12906,6 +12929,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12918,6 +12942,7 @@
"mode": "chat",
"output_cost_per_token": 6.8e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12930,6 +12955,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12942,6 +12968,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12954,6 +12981,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12966,6 +12994,7 @@
"mode": "chat",
"output_cost_per_token": 6e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12978,6 +13007,7 @@
"mode": "chat",
"output_cost_per_token": 6.4e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -12990,6 +13020,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13002,6 +13033,7 @@
"mode": "chat",
"output_cost_per_token": 5.2e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13015,6 +13047,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": false
},
@ -13027,7 +13060,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-multimodal-instruct": {
"deprecation_date": "2027-01-17",
@ -13041,6 +13075,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_audio_input": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_vision": true
},
"azure_ai/Phi-4-mini-reasoning": {
@ -13052,7 +13087,8 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/Phi-4-reasoning": {
"deprecation_date": "2027-01-17",
@ -13064,6 +13100,7 @@
"output_cost_per_token": 5e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_reasoning": true
},
@ -13106,6 +13143,7 @@
"output_cost_per_token": 1e-05,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/doc-intelligence/prebuilt-read": {
@ -13144,6 +13182,7 @@
"mode": "chat",
"output_cost_per_token": 5.4e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -13202,6 +13241,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13217,6 +13257,7 @@
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13230,6 +13271,7 @@
"mode": "chat",
"output_cost_per_token": 4.56e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-pro": {
@ -13242,6 +13284,7 @@
"output_cost_per_token": 3.48e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 1.45e-07,
@ -13257,6 +13300,7 @@
"output_cost_per_token": 5.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"cache_read_input_token_cost": 2.8e-08,
@ -13273,6 +13317,7 @@
"output_cost_per_token": 1.32e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -13306,6 +13351,7 @@
"output_cost_per_token": 1.5e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13323,6 +13369,7 @@
"output_cost_per_token_above_200k_tokens": 5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13343,6 +13390,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/grok/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -13361,6 +13409,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13379,6 +13428,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13395,6 +13445,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13409,6 +13460,7 @@
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
@ -13424,6 +13476,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_web_search": true
@ -13436,7 +13489,8 @@
"max_output_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 0.00971,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models"
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false
},
"azure_ai/jamba-instruct": {
"deprecation_date": "2025-03-01",
@ -13447,6 +13501,7 @@
"mode": "chat",
"output_cost_per_token": 7e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/kimi-k2.5": {
@ -13459,6 +13514,7 @@
"output_cost_per_token": 3e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
@ -13482,6 +13538,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
@ -13498,6 +13555,7 @@
"output_cost_per_token": 4e-08,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.ministral-3b-2410-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large": {
@ -13510,6 +13568,7 @@
"output_cost_per_token": 1.2e-05,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-2407": {
@ -13522,6 +13581,7 @@
"output_cost_per_token": 6e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-latest": {
@ -13533,6 +13593,7 @@
"output_cost_per_token": 6e-06,
"source": "https://marketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-large-3": {
@ -13544,6 +13605,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -13557,6 +13619,7 @@
"output_cost_per_token": 2e-06,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-nemo": {
@ -13568,7 +13631,8 @@
"mode": "chat",
"output_cost_per_token": 1.5e-07,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true
"supports_function_calling": true,
"supports_pdf_input": false
},
"azure_ai/mistral-small": {
"deprecation_date": "2025-07-31",
@ -13580,6 +13644,7 @@
"output_cost_per_token": 3e-06,
"source": "https://learn.microsoft.com/en-us/azure/foundry/openai/concepts/retired-models",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"azure_ai/mistral-small-2503": {
@ -13591,6 +13656,7 @@
"mode": "chat",
"output_cost_per_token": 3e-07,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_vision": true
},
@ -52006,6 +52072,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52026,6 +52093,7 @@
],
"supports_assistant_prefill": false,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52777,6 +52845,7 @@
"output_cost_per_token": 2.5e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true,
"supports_web_search": true
},
@ -52794,6 +52863,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -52811,6 +52881,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -52989,6 +53060,7 @@
"output_cost_per_token": 3.6e-07,
"output_cost_per_token_batches": 1.8e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true
},
"vertex_ai/openai/gpt-oss-20b-maas": {
@ -53000,6 +53072,7 @@
"mode": "chat",
"output_cost_per_token": 2.5e-07,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supports_pdf_input": false,
"supports_reasoning": true,
"cache_read_input_token_cost": 7e-09,
"input_cost_per_token_batches": 3.5e-08,
@ -53131,6 +53204,7 @@
"us-south1"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": {
@ -53149,6 +53223,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": {
@ -53164,6 +53239,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": {
@ -53179,6 +53255,7 @@
"global"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_tool_choice": true
},
"vertex_ai/veo-3.1-generate-preview": {
@ -61177,6 +61254,126 @@
],
"supports_audio_input": true
},
"soniox/stt-rt-v5": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-preview": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-preview-v2": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-v3": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-v3-preview": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"soniox/stt-rt-v4": {
"input_cost_per_second": 3.333e-05,
"litellm_provider": "soniox",
"metadata": {
"calculation": "$0.12/3600 seconds = $0.00003333 per second",
"original_pricing_per_hour": 0.12
},
"mode": "audio_transcription",
"source": "https://soniox.com/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"tensormesh/Qwen/Qwen3.5-397B-A17B-FP8": {
"litellm_provider": "tensormesh",
"mode": "chat",
@ -67202,6 +67399,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67216,6 +67414,7 @@
"mode": "chat",
"output_cost_per_token": 1.2e-06,
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -67258,6 +67457,7 @@
"output_cost_per_token": 1.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67272,6 +67472,7 @@
"output_cost_per_token": 3.1e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67286,6 +67487,7 @@
"output_cost_per_token": 5.5e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67300,6 +67502,7 @@
"output_cost_per_token": 6.25e-07,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -67316,6 +67519,7 @@
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -67331,6 +67535,7 @@
"output_cost_per_token": 3.2e-06,
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_reasoning": true,
"supports_tool_choice": true
},
@ -67349,6 +67554,7 @@
"supported_output_modalities": [
"text"
],
"supports_pdf_input": false,
"supports_response_schema": true,
"supports_vision": true
},
@ -67358,7 +67564,8 @@
"output_cost_per_token": 5.4e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3-0324": {
"deprecation_date": "2026-07-13",
@ -67366,7 +67573,8 @@
"output_cost_per_token": 4.56e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/deepseek-v3.1": {
"deprecation_date": "2026-07-13",
@ -67374,7 +67582,8 @@
"output_cost_per_token": 4.94e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3": {
"deprecation_date": "2026-05-01",
@ -67382,7 +67591,8 @@
"output_cost_per_token": 1.5e-05,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-3-mini": {
"deprecation_date": "2026-05-01",
@ -67390,7 +67600,8 @@
"output_cost_per_token": 1.27e-06,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-non-reasoning": {
"deprecation_date": "2026-05-01",
@ -67398,7 +67609,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"azure_ai/grok-4-fast-reasoning": {
"deprecation_date": "2026-05-01",
@ -67406,7 +67618,8 @@
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
"mode": "chat",
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
"supports_pdf_input": false
},
"bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b": {
"input_cost_per_token": 7.2e-08,
@ -73076,6 +73289,7 @@
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supported_regions": ["global"],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
@ -73691,6 +73905,7 @@
"output_cost_per_token": 3.5e-06,
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
@ -74745,6 +74960,7 @@
"text"
],
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_tool_choice": true,
@ -74993,6 +75209,7 @@
"output_cost_per_token_above_200k_tokens": 1.2e-05,
"source": "https://docs.x.ai/developers/models/grok-4.7",
"supports_function_calling": true,
"supports_pdf_input": false,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,

View file

@ -56,6 +56,7 @@ editable-profile = "dev"
include = [
{ path = "rust-toolchain.toml", format = "sdist" },
{ path = ".cargo/config.toml", format = "sdist" },
{ path = "model_prices_and_context_window.json", format = "sdist" },
"litellm/router_strategy/complexity_router/artifacts/*.json",
"litellm/router_strategy/complexity_router/fuse_presets.json",
"litellm/proxy/model_insights_tasks.json",

View file

@ -3724,7 +3724,7 @@
"ocr": false,
"search": false,
"moderations": false,
"realtime": false,
"realtime": true,
"video_generations": false,
"batches": false,
"messages": false,
@ -4910,6 +4910,33 @@
"vector_stores_search": true
}
},
"opensearch": {
"display_name": "Amazon OpenSearch (`opensearch`)",
"url": "https://docs.litellm.ai/docs/providers/opensearch_vector_stores",
"endpoints": {
"chat_completions": false,
"responses": false,
"text_completion": false,
"embeddings": false,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
"rerank": false,
"ocr": false,
"search": false,
"moderations": false,
"realtime": false,
"video_generations": false,
"batches": false,
"messages": false,
"interactions": false,
"systemone": false,
"a2a": false,
"generateContent": false,
"vector_stores_search": true
}
},
"helicone": {
"display_name": "Helicone (`helicone`)",
"url": "https://docs.litellm.ai/docs/providers/helicone",

View file

@ -335,6 +335,7 @@ include = [
"litellm/proxy/public_endpoints/whats_new.json",
"litellm/proxy/common_utils/codex_base_instructions.md",
"litellm/proxy/common_utils/codex_bundled_models_0.159.3.json",
{ path = "model_prices_and_context_window.json", format = "sdist" },
]
exclude = [
"litellm/proxy/enterprise",
@ -357,6 +358,7 @@ cache-keys = [
{ file = "litellm-rust/Cargo.lock" },
{ file = "litellm-rust/Cargo.toml" },
{ file = "litellm-rust/crates/**/*" },
{ file = "model_prices_and_context_window.json" },
]
constraint-dependencies = [
"tornado>=6.5.8",

View file

@ -9,7 +9,15 @@ from pathlib import Path
from typing import Final
ROOT: Final = Path(__file__).resolve().parents[1]
SOURCES: Final = ("litellm", "litellm-rust", ".cargo", "rust-toolchain.toml", "README.md", "LICENSE")
SOURCES: Final = (
"litellm",
"litellm-rust",
".cargo",
"rust-toolchain.toml",
"model_prices_and_context_window.json",
"README.md",
"LICENSE",
)
def stage_core_distribution(source: Path, destination: Path) -> None:

View file

@ -132,7 +132,9 @@ def test_core_artifacts_exclude_local_configuration(distributions: tuple[Path, P
assert not any(member.name.endswith(f"/{name}") for member in archive.getmembers())
@pytest.mark.parametrize("relative_path", ["rust-toolchain.toml", ".cargo/config.toml"])
@pytest.mark.parametrize(
"relative_path", ["rust-toolchain.toml", ".cargo/config.toml", "model_prices_and_context_window.json"]
)
def test_core_sdist_preserves_native_build_configuration(distribution_directory: Path, relative_path: str) -> None:
sdists: Final = tuple(distribution_directory.glob("litellm_core-*.tar.gz"))
assert len(sdists) == 1

View file

@ -2,6 +2,7 @@
- {id: llm.completions.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: completions, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_completions_endpoint_e2e.py", rationale: "Legacy text /completions endpoint, second-highest production request volume"}
- {id: llm.embeddings.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_embeddings_endpoint_e2e.py:23", rationale: "Core endpoint, live vector response"}
- {id: llm.embeddings.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.3 / LIT-4778", rationale: "Missing model/input on /embeddings return client errors"}
- {id: llm.embeddings.openai.supported_endpoints_gate.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], fail_before_fix: proven, source: "test_embedding_only_supported_endpoints_e2e.py", rationale: "Explicit endpoint restrictions preserve real embeddings, refuse chat with a gateway 400, and route chat through a live fallback; zero-provider-call contracts live in integration/routing/test_non_chat_model_endpoint_gate.py"}
- {id: llm.embeddings.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "SPEND_TRACKING_COVERAGE_MATRIX.md:34", rationale: "Cost tracking on embeddings"}
- {id: llm.embeddings.openai.passthrough.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "POST /openai_passthrough/v1/embeddings is costed; the route wrote no spend row at all, so budgets never saw traffic OpenAI was billing for (GitHub issue #36646)"}
- {id: llm.embeddings.azure_openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "llms/azure/azure.py", rationale: "Azure embeddings via translation"}

View file

@ -0,0 +1,119 @@
"""Live provider checks for embeddings, the chat refusal, and chat fallback from an explicitly restricted model."""
from __future__ import annotations
import dataclasses
from typing import Final
import pytest
from e2e_config import unique_marker
from e2e_metadata import Domain, Mode, Provider, Route, Subject, meta
from lifecycle import ResourceManager
from models import LiteLLMParamsBody, ModelInfoBody, ModelMode, ModelNewBody
from openai import BadRequestError, OpenAI
from proxy_client import ProxyClient
from sdk_clients import NO_PROXY_CACHE, SdkClients
pytestmark = pytest.mark.e2e
EMBEDDING_BACKEND: Final = "openai/text-embedding-3-small"
CHAT_FALLBACK_BACKEND: Final = "openai/gpt-5.4-nano"
EMBEDDINGS_ONLY: Final = ("/v1/embeddings",)
PROMPT: Final = "Say this is a test!"
def _subject(route: Route) -> Subject:
return Subject(
domain=Domain.LLM_TRANSLATION,
route=route,
providers=(Provider.OPENAI,),
models=(EMBEDDING_BACKEND,),
mode=Mode.NONSTREAM,
)
def _register(
proxy: ProxyClient,
resources: ResourceManager,
prefix: str,
supported_endpoints: tuple[str, ...] | None,
backend: str = EMBEDDING_BACKEND,
mode: ModelMode = "embedding",
) -> str:
model: Final = f"{prefix}-{unique_marker()}"
model_id: Final = proxy.register_model(
ModelNewBody(
model_name=model,
litellm_params=LiteLLMParamsBody(model=backend, api_key="os.environ/OPENAI_API_KEY"),
model_info=ModelInfoBody(
enforce_supported_endpoints=supported_endpoints is not None,
mode=mode,
supported_endpoints=None if supported_endpoints is None else list(supported_endpoints),
),
)
)
resources.defer(lambda: proxy.delete_model(model_id))
return model
def _openai_at(sdk: SdkClients, api_prefix: str, key: str) -> OpenAI:
return dataclasses.replace(sdk, base_url=f"{sdk.base_url}{api_prefix}").openai(key)
class TestEmbeddingOnlySupportedEndpoints:
@pytest.mark.covers("llm.embeddings.openai.supported_endpoints_gate.nonstream.works")
@meta(_subject(Route.EMBEDDINGS))
@pytest.mark.parametrize(
("prefix", "supported_endpoints"),
(
pytest.param("e2e-embed-only", EMBEDDINGS_ONLY, id="embed-only"),
pytest.param("e2e-embed-control", None, id="control"),
),
)
def test_embeddings_return_a_vector(
self,
proxy: ProxyClient,
resources: ResourceManager,
sdk: SdkClients,
prefix: str,
supported_endpoints: tuple[str, ...] | None,
) -> None:
model: Final = _register(proxy, resources, prefix, supported_endpoints)
embeddings: Final = sdk.openai(resources.key()).embeddings.create(
model=model, input=PROMPT, extra_body=NO_PROXY_CACHE
)
assert embeddings.data, f"/embeddings returned no data for {model}: {embeddings!r}"
assert any(component != 0.0 for component in embeddings.data[0].embedding), (
f"/embeddings returned an empty or all-zero vector for {model}"
)
@pytest.mark.covers("llm.embeddings.openai.supported_endpoints_gate.nonstream.works")
@meta(dataclasses.replace(_subject(Route.CHAT_COMPLETIONS), models=(EMBEDDING_BACKEND, CHAT_FALLBACK_BACKEND)))
def test_chat_completions_with_a_fallback_are_answered_by_the_fallback(
self, proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients
) -> None:
model: Final = _register(proxy, resources, "e2e-embed-only", EMBEDDINGS_ONLY)
fallback: Final = _register(proxy, resources, "e2e-chat-fallback", None, CHAT_FALLBACK_BACKEND, "chat")
completion: Final = _openai_at(sdk, "/v1", resources.key()).chat.completions.create(
model=model,
messages=[{"role": "user", "content": PROMPT}],
extra_body={**NO_PROXY_CACHE, "fallbacks": [fallback]},
)
assert completion.choices and completion.choices[0].message.content, (
f"the fallback {fallback} should answer a chat call to {model}, got {completion!r}"
)
@pytest.mark.covers("llm.embeddings.openai.supported_endpoints_gate.nonstream.works")
@meta(_subject(Route.CHAT_COMPLETIONS))
def test_chat_completions_without_a_fallback_are_refused_by_the_gateway(
self, proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients
) -> None:
model: Final = _register(proxy, resources, "e2e-embed-only", EMBEDDINGS_ONLY)
with pytest.raises(BadRequestError) as refused:
_ = _openai_at(sdk, "/v1", resources.key()).chat.completions.create(
model=model, messages=[{"role": "user", "content": PROMPT}], extra_body=NO_PROXY_CACHE
)
assert (
f"/v1/chat/completions is not supported by model={model}. Supported endpoints: /v1/embeddings"
in refused.value.message
), f"chat on {model} should be refused by the gateway before any provider call, got {refused.value!r}"

View file

@ -1416,7 +1416,7 @@ class LiteLLMParamsBody(BaseModel):
order: int | None = None
ModelMode = Literal["chat", "batch", "realtime", "image_generation"]
ModelMode = Literal["chat", "embedding", "batch", "realtime", "image_generation"]
class TokenCountModelBody(BaseModel, frozen=True):
@ -1425,6 +1425,7 @@ class TokenCountModelBody(BaseModel, frozen=True):
class ModelInfoBody(BaseModel):
enforce_supported_endpoints: bool | None = None
# id is left unset so the proxy assigns a unique model_id per deployment.
# Pinning it to the model_name made re-registrations of a fixed-name model
# (e.g. the batch suite's openai-batch) collide on the model_id unique

View file

@ -1,6 +1,7 @@
import uuid
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from functools import partial
from itertools import chain
from pathlib import Path
@ -10,6 +11,7 @@ import httpx
import psutil
import pytest
from pydantic import JsonValue
from redis import Redis
from tests.integration._support.client import (
Gateway,
@ -27,7 +29,7 @@ from tests.integration._support.process import (
setup_only_proxy_run,
worker_configuration,
)
from tests.integration._support.redis_process import owned_redis
from tests.integration._support.redis_process import OwnedRedis, owned_redis
pytestmark: Final = [pytest.mark.timeout(300), pytest.mark.shares_worker_state]
@ -385,6 +387,39 @@ def test_fallback_write_survives_a_redis_outage(gateway: Gateway, tmp_path: Path
cache.start()
@contextmanager
def _refusing_cache_deletes(cache: OwnedRedis) -> Iterator[None]:
with Redis(host=cache.host, port=cache.port) as client:
client.acl_setuser("default", enabled=True, commands=["-del", "-unlink"])
try:
yield
finally:
client.acl_setuser("default", enabled=True, commands=["+del", "+unlink"])
@pytest.mark.usefixtures("migrated_database")
def test_consecutive_creates_keep_every_rule_when_redis_refuses_the_cache_eviction(
gateway: Gateway, tmp_path: Path
) -> None:
with owned_redis(tmp_path) as cache:
overrides: Final = {"REDIS_HOST": cache.host, "REDIS_PORT": str(cache.port)}
with owned_proxy_process(gateway, tmp_path, overrides, database_setup=()) as owned:
with owned.gateway.scenario() as scenario:
target: Final = scenario.model(model=f"openai/x3t-{uuid.uuid4().hex}")
first: Final = scenario.model(model=f"openai/x3a-{uuid.uuid4().hex}")
second: Final = scenario.model(model=f"openai/x3b-{uuid.uuid4().hex}")
for model in (target, first, second):
_wait_until_served(owned.gateway, model)
with _refusing_cache_deletes(cache):
created: Final = tuple(
_create_fallback(owned.gateway, scenario, model, [target]) for model in (first, second)
)
assert all(response.status_code == 200 for response in created), [r.text for r in created]
covered: Final = _covered_models(_stored_fallbacks())
assert {first, second} <= covered, covered
assert EVICTION_WARNING in owned.log.read_text(), owned.log.read_text()[-3000:]
@pytest.mark.usefixtures("migrated_database")
def test_fallback_write_survives_a_worker_kill(gateway: Gateway, tmp_path: Path) -> None:
with owned_proxy_process(gateway, tmp_path, {}, workers=2, database_setup=()) as owned:

View file

@ -0,0 +1,421 @@
import json
import uuid
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import anthropic
import pytest
from integration._support.client import Gateway, Scenario, string_value
from integration._support.pdf_document import LETTER, Page, document, encoded, pdf_bytes
from integration._support.process import owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_API_KEY: Final = "synthetic-pdf-document-key"
_ANSWER: Final = "The codeword is PELICAN-42."
_TITLE: Final = "Quarterly report"
_URL: Final = "https://example.com/brief.pdf"
_SAP_TOKEN_TARGET: Final = "/oauth/token"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_PARTS: Final = TypeAdapter(list[dict[str, JsonValue]])
def _pdf(text: str) -> str:
return encoded(pdf_bytes((Page(text=text), LETTER)))
def _chat_completion(identity: str, model: str) -> Reply:
return Reply(
body=json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": model,
"choices": [
{"index": 0, "message": {"role": "assistant", "content": _ANSWER}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 914, "completion_tokens": 9, "total_tokens": 923},
}
).encode()
)
def _only_body(wire: Wire) -> dict[str, JsonValue]:
received: Final = tuple(
request
for request in wire.drain()
if request.method == "POST" and not request.target.startswith(_SAP_TOKEN_TARGET)
)
assert len(received) == 1, received
return _JSON_OBJECT.validate_json(received[0].body)
def _user_parts(body: dict[str, JsonValue], key: str) -> list[dict[str, JsonValue]]:
turns: Final = _PARTS.validate_python(body[key])
assert len(turns) == 1, turns
return _PARTS.validate_python(turns[0]["content" if key == "messages" else "parts"])
def test_anthropic_sdk_pdf_documents_reach_azure_as_file_parts_named_by_title(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
backend: Final = f"gpt-pdf-{marker[:8]}"
identity: Final = f"chatcmpl-{marker}"
titled: Final = _pdf(f"PELICAN-42 {marker}")
untitled: Final = _pdf(f"ORCHID-77 {marker}")
question: Final = f"What codeword does each document carry? {marker}"
with wire_server(lambda _: _chat_completion(identity, backend)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"azure/{backend}", api_base=wire.url, api_key=_API_KEY, api_version="2024-10-21"
)
client: Final = anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0)
message: Final = client.messages.create(
model=model,
max_tokens=64,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"title": _TITLE,
"source": {"type": "base64", "media_type": "application/pdf", "data": titled},
},
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": untitled},
},
{"type": "document", "source": {"type": "url", "url": _URL}},
{"type": "text", "text": question},
],
}
],
)
assert message.id == identity
assert [(block.type, getattr(block, "text", None)) for block in message.content] == [("text", _ANSWER)]
assert _user_parts(_only_body(wire), "messages") == [
{"type": "file", "file": {"filename": _TITLE, "file_data": f"data:application/pdf;base64,{titled}"}},
{
"type": "file",
"file": {"filename": "document.pdf", "file_data": f"data:application/pdf;base64,{untitled}"},
},
{"type": "image_url", "image_url": {"url": _URL}},
{"type": "text", "text": question},
]
@dataclass(frozen=True, slots=True)
class _Reader:
deploy: Callable[[Scenario, Wire], str]
reply: Callable[[Request], Reply]
pdf_parts: Callable[[dict[str, JsonValue]], Sequence[JsonValue]]
def _gemini_reply(_: Request) -> Reply:
return Reply(
body=json.dumps(
{
"candidates": [
{"content": {"parts": [{"text": _ANSWER}], "role": "model"}, "finishReason": "STOP", "index": 0}
],
"usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 9, "totalTokenCount": 309},
"modelVersion": "gemini-2.5-flash",
}
).encode()
)
def _bedrock_reply(_: Request) -> Reply:
return Reply(
body=json.dumps(
{
"output": {"message": {"role": "assistant", "content": [{"text": _ANSWER}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 300, "outputTokens": 9, "totalTokens": 309},
"metrics": {"latencyMs": 1},
}
).encode()
)
def _anthropic_reply(_: Request) -> Reply:
return Reply(
body=json.dumps(
{
"id": "msg_pdf",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": _ANSWER}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 300, "output_tokens": 9},
}
).encode()
)
def _sap_reply(request: Request) -> Reply:
if request.target.startswith(_SAP_TOKEN_TARGET):
return Reply(body=json.dumps({"access_token": "scripted", "expires_in": 3600}).encode())
match request.target:
case "/v2/lm/deployments":
deployment: Final = {
"scenarioId": "orchestration",
"configurationId": "orchestration-config",
"deploymentUrl": f"http://{request.headers['host']}/sap-deployment",
"createdAt": "2026-01-01T00:00:00Z",
}
return Reply(body=json.dumps({"resources": [deployment]}).encode())
case "/v2/lm/configurations/orchestration-config":
return Reply(body=json.dumps({"executableId": "orchestration"}).encode())
case _:
completion: Final = _JSON_OBJECT.validate_json(_chat_completion("chatcmpl-sap-pdf", "claude").body)
return Reply(body=json.dumps({"final_result": completion}).encode())
def _snowflake(scenario: Scenario, wire: Wire) -> str:
return scenario.model(model="snowflake/claude-sonnet-4-5", api_base=wire.url, api_key=_API_KEY)
def _sap(scenario: Scenario, wire: Wire) -> str:
service_key: Final = {
"clientid": "scripted",
"clientsecret": _API_KEY,
"url": wire.url,
"serviceurls": {"AI_API_URL": wire.url},
}
return scenario.model(model="sap/anthropic--claude-4.5-sonnet", api_base=wire.url, api_key=json.dumps(service_key))
def _snowflake_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
return tuple(part.get("source") for part in _user_parts(body, "messages") if part.get("type") == "document")
def _sap_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
config: Final = _JSON_OBJECT.validate_python(body["config"])
templating: Final = _JSON_OBJECT.validate_python(
_JSON_OBJECT.validate_python(config["modules"])["prompt_templating"]
)
prompt: Final = _JSON_OBJECT.validate_python(templating["prompt"])
turns: Final = _PARTS.validate_python(prompt["template"])
assert len(turns) == 1, turns
parts: Final = _PARTS.validate_python(turns[0]["content"])
return tuple(
_JSON_OBJECT.validate_python(part["image_url"]).get("url") for part in parts if part.get("type") == "image_url"
)
def _gemini(scenario: Scenario, wire: Wire) -> str:
return scenario.model(model="gemini/gemini-2.5-flash", api_base=wire.url, api_key=_API_KEY)
def _bedrock(scenario: Scenario, wire: Wire) -> str:
return scenario.model(
model="bedrock/converse/us.amazon.nova-2-lite-v1:0",
api_key=None,
aws_access_key_id="AKIDSYNTHETIC",
aws_secret_access_key="synthetic-secret",
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint=wire.url,
)
def _gemini_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
return tuple(part.get("inline_data") for part in _user_parts(body, "contents") if "inline_data" in part)
def _bedrock_pdf_parts(body: dict[str, JsonValue]) -> Sequence[JsonValue]:
documents: Final = tuple(
_JSON_OBJECT.validate_python(part["document"]) for part in _user_parts(body, "messages") if "document" in part
)
return tuple({"format": item.get("format"), "source": item.get("source")} for item in documents)
@pytest.mark.parametrize(
("reader", "expected"),
[
pytest.param(
_Reader(_gemini, _gemini_reply, _gemini_pdf_parts),
lambda data: [{"mime_type": "application/pdf", "data": data}],
id="gemini",
),
pytest.param(
_Reader(_bedrock, _bedrock_reply, _bedrock_pdf_parts),
lambda data: [{"format": "pdf", "source": {"bytes": data}}],
id="bedrock-converse",
),
pytest.param(
_Reader(_snowflake, _anthropic_reply, _snowflake_pdf_parts),
lambda data: [{"type": "base64", "media_type": "application/pdf", "data": data}],
id="snowflake-claude",
),
pytest.param(
_Reader(_sap, _sap_reply, _sap_pdf_parts),
lambda data: [f"data:application/pdf;base64,{data}"],
id="sap-orchestration",
),
],
)
def test_messages_pdf_still_reaches_providers_that_read_pdfs_in_their_own_document_shape(
gateway: Gateway, reader: _Reader, expected: Callable[[str], Sequence[JsonValue]]
) -> None:
marker: Final = uuid.uuid4().hex
data: Final = _pdf(f"PELICAN-42 {marker}")
with wire_server(reader.reply) as wire, gateway.scenario() as scenario:
model: Final = reader.deploy(scenario, wire)
response: Final = gateway.request(
"POST",
"/v1/messages",
{
"model": model,
"max_tokens": 64,
"messages": [
{
"role": "user",
"content": [
document({"type": "base64", "media_type": "application/pdf", "data": data}, title=_TITLE),
{"type": "text", "text": f"What is the codeword? {marker}"},
],
}
],
},
headers={"anthropic-version": "2023-06-01"},
)
assert response.status_code == 200, response.text
assert _JSON_OBJECT.validate_json(response.content)["content"] == [{"type": "text", "text": _ANSWER}]
assert list(reader.pdf_parts(_only_body(wire))) == list(expected(data))
def _prompt_security(job: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
match (request.method, request.target.partition("?")[0]):
case ("POST", "/api/sanitizeFile"):
return Reply(body=json.dumps({"jobId": job}).encode())
case ("GET", "/api/sanitizeFile"):
return Reply(
body=json.dumps(
{
"status": "done",
"content": "",
"metadata": {"action": "block", "violations": ["Malicious File"]},
}
).encode()
)
case _:
return Reply(body=json.dumps({"result": {"prompt": {"action": "allow"}}}).encode())
return respond
def _delete_guardrail(gateway: Gateway, identity: str) -> None:
response: Final = gateway.request("DELETE", f"/guardrails/{identity}")
assert response.status_code == 200, response.text
def test_prompt_security_still_sanitizes_a_messages_pdf_and_blocks_it_before_the_provider(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
pdf: Final = pdf_bytes((Page(text=f"PELICAN-42 {marker}"), LETTER))
with (
wire_server(lambda _: _chat_completion(f"chatcmpl-{marker}", "gpt-pdf")) as provider,
wire_server(_prompt_security(f"job-{marker}")) as sink,
gateway.scenario() as scenario,
):
model: Final = scenario.model(
model=f"azure/gpt-pdf-{marker[:8]}", api_base=provider.url, api_key=_API_KEY, api_version="2024-10-21"
)
name: Final = f"pdf-prompt-security-{marker}"
created: Final = gateway.post(
"/guardrails",
{
"guardrail": {
"guardrail_name": name,
"litellm_params": {
"guardrail": "prompt_security",
"mode": "pre_call",
"api_key": "synthetic-prompt-security-key",
"api_base": sink.url,
},
}
},
)
scenario.cleanups.callback(_delete_guardrail, gateway, string_value(created["guardrail_id"]))
response: Final = gateway.request(
"POST",
"/v1/messages",
{
"model": model,
"max_tokens": 64,
"guardrails": [name],
"messages": [
{
"role": "user",
"content": [
document({"type": "base64", "media_type": "application/pdf", "data": encoded(pdf)}),
{"type": "text", "text": f"Summarize this PDF {marker}"},
],
}
],
},
headers={"anthropic-version": "2023-06-01"},
)
assert response.status_code == 400, response.text
assert "Malicious File" in response.text
assert provider.drain() == ()
uploads: Final = tuple(
request for request in sink.drain() if (request.method, request.target) == ("POST", "/api/sanitizeFile")
)
assert len(uploads) == 1, uploads
assert pdf in uploads[0].body
def _gigachat_reply(file_id: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.target.endswith("/files"):
return Reply(body=json.dumps({"id": file_id, "object": "file", "purpose": "general"}).encode())
return _chat_completion(f"chatcmpl-{file_id}", "GigaChat-Pro")
return respond
def test_anthropic_sdk_pdf_document_reaches_gigachat_as_an_uploaded_attachment(
gateway: Gateway, tmp_path: Path
) -> None:
marker: Final = uuid.uuid4().hex
file_id: Final = f"file-{marker}"
pdf: Final = pdf_bytes((Page(text=f"PELICAN-42 {marker}"), LETTER))
with (
wire_server(_gigachat_reply(file_id)) as wire,
owned_proxy_process(gateway, tmp_path, {"GIGACHAT_ACCESS_TOKEN": _API_KEY}) as owned,
owned.gateway.scenario() as scenario,
):
model: Final = scenario.model(model="gigachat/GigaChat-Pro", api_base=f"{wire.url}/v1", api_key=_API_KEY)
client: Final = anthropic.Anthropic(
base_url=str(owned.gateway.client.base_url), api_key=owned.gateway.key, max_retries=0
)
message: Final = client.messages.create(
model=model,
max_tokens=64,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": encoded(pdf)},
},
{"type": "text", "text": f"What is the codeword? {marker}"},
],
}
],
)
assert [(block.type, getattr(block, "text", None)) for block in message.content] == [("text", _ANSWER)]
upload, chat = (request for request in wire.drain() if request.method == "POST")
assert upload.target == "/v1/files", upload.target
assert upload.headers["authorization"] == f"Bearer {_API_KEY}", sorted(upload.headers)
assert pdf in upload.body
assert b"application/pdf" in upload.body
assert chat.target == "/v1/chat/completions", chat.target
turns: Final = _PARTS.validate_python(_JSON_OBJECT.validate_json(chat.body)["messages"])
assert turns[-1]["attachments"] == [file_id], chat.body

View file

@ -0,0 +1,190 @@
import json
import uuid
from collections.abc import Callable, Iterator
from itertools import chain
from typing import Final
import httpx
from integration._support.client import Gateway
from integration._support.wire import Reply, Request, Wire, wire_server
from integration.caching.response_cache_case import await_entry, redis_store
from pydantic import JsonValue, TypeAdapter
from redis import Redis
_BACKEND: Final = "z-ai/glm-5.2"
_API_KEY: Final = "synthetic-openrouter-key"
_ANSWER: Final = "The report covers revenue."
_URL_CITATION: Final[dict[str, JsonValue]] = {
"type": "url_citation",
"url_citation": {
"url": "https://example.com/report",
"title": "Report",
"start_index": 0,
"end_index": 10,
"content": "cited passage",
},
}
_FILE_ANNOTATION: Final[dict[str, JsonValue]] = {
"type": "file",
"file": {"hash": "sha-report", "name": "report.pdf", "content": [{"type": "text", "text": "parsed page one"}]},
}
_USAGE: Final[dict[str, JsonValue]] = {"prompt_tokens": 40, "completion_tokens": 6, "total_tokens": 46}
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_CHOICES: Final = TypeAdapter(list[dict[str, JsonValue]])
def _completion(identity: str) -> bytes:
return json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": _BACKEND,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": _ANSWER,
"annotations": [_URL_CITATION, _FILE_ANNOTATION],
},
"finish_reason": "stop",
}
],
"usage": _USAGE,
}
).encode()
def _frame(frame: dict[str, JsonValue]) -> bytes:
return b"data: " + json.dumps(frame).encode() + b"\n\n"
def _stream(identity: str) -> Reply:
chunk: Final[dict[str, JsonValue]] = {
"id": identity,
"object": "chat.completion.chunk",
"created": 1,
"model": _BACKEND,
}
return Reply(
content_type="text/event-stream",
chunks=(
_frame({**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": _ANSWER}}]}),
_frame({**chunk, "choices": [{"index": 0, "delta": {"annotations": [_URL_CITATION, _FILE_ANNOTATION]}}]}),
_frame({**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": _USAGE}),
b"data: [DONE]\n\n",
),
)
def _responder(identity: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if _JSON_OBJECT.validate_json(request.body).get("stream") is True:
return _stream(identity)
return Reply(body=_completion(identity))
return respond
def _chat_body(model: str, marker: str, stream: bool) -> dict[str, JsonValue]:
return {
"model": model,
"messages": [{"role": "user", "content": f"Summarize the report {marker}"}],
"stream": stream,
}
def _posted(wire: Wire) -> int:
return sum(1 for request in wire.drain() if request.method == "POST")
def _message(response: httpx.Response) -> dict[str, JsonValue]:
assert response.status_code == 200, response.text
choices: Final = _CHOICES.validate_python(_JSON_OBJECT.validate_json(response.content)["choices"])
return _JSON_OBJECT.validate_python(choices[0]["message"])
def _assert_split(message: dict[str, JsonValue]) -> None:
assert message["content"] == _ANSWER, message
assert message["annotations"] == [_URL_CITATION], message
assert _JSON_OBJECT.validate_python(message["provider_specific_fields"])["annotations"] == [_FILE_ANNOTATION]
def test_file_annotation_reaches_the_caller_in_provider_specific_fields_and_survives_a_cache_hit(
gateway: Gateway,
) -> None:
marker: Final = uuid.uuid4().hex
identity: Final = f"gen-{marker}"
with wire_server(_responder(identity)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"openrouter/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
first: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker, stream=False))
_assert_split(_message(first))
assert _posted(wire) == 1
await_entry(redis_store(), identity)
cached: Final = gateway.request("POST", "/v1/chat/completions", _chat_body(model, marker, stream=False))
assert cached.headers.get("x-litellm-cache-key"), dict(cached.headers)
_assert_split(_message(cached))
assert _posted(wire) == 0
def _deltas(response: httpx.Response) -> Iterator[dict[str, JsonValue]]:
for line in response.iter_lines():
if not line.startswith("data: ") or line == "data: [DONE]":
continue
choices = _CHOICES.validate_python(_JSON_OBJECT.validate_json(line.removeprefix("data: "))["choices"])
yield from (_JSON_OBJECT.validate_python(choice["delta"]) for choice in choices)
def _streamed(gateway: Gateway, body: dict[str, JsonValue]) -> tuple[dict[str, JsonValue], ...]:
with gateway.client.stream(
"POST", "/v1/chat/completions", json=body, headers={"Authorization": f"Bearer {gateway.key}"}
) as response:
assert response.status_code == 200, response.read()
return tuple(_deltas(response))
def _listed(value: JsonValue) -> list[JsonValue]:
return value if isinstance(value, list) else []
def _provider_fields(delta: dict[str, JsonValue]) -> dict[str, JsonValue]:
return _JSON_OBJECT.validate_python(delta.get("provider_specific_fields") or {})
def _annotations(deltas: tuple[dict[str, JsonValue], ...]) -> list[JsonValue]:
return list(chain.from_iterable(_listed(delta.get("annotations")) for delta in deltas))
def _provider_annotations(deltas: tuple[dict[str, JsonValue], ...]) -> list[JsonValue]:
return list(chain.from_iterable(_listed(_provider_fields(delta).get("annotations")) for delta in deltas))
def _text(deltas: tuple[dict[str, JsonValue], ...]) -> str:
return "".join(str(delta.get("content") or "") for delta in deltas)
def _cached_message(store: Redis, key: str) -> dict[str, JsonValue]:
raw: Final = store.get(key)
assert isinstance(raw, bytes), key
response: Final = _JSON_OBJECT.validate_json(raw)["response"]
stored: Final = _JSON_OBJECT.validate_json(response) if isinstance(response, str) else response
choices: Final = _CHOICES.validate_python(_JSON_OBJECT.validate_python(stored)["choices"])
return _JSON_OBJECT.validate_python(choices[0]["message"])
def test_streamed_annotations_reach_the_caller_as_sent_and_the_cached_stream_splits_them(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
identity: Final = f"gen-{marker}"
with wire_server(_responder(identity)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(model=f"openrouter/{_BACKEND}", api_base=wire.url, api_key=_API_KEY)
live: Final = _streamed(gateway, _chat_body(model, marker, stream=True))
assert _text(live) == _ANSWER, live
assert _annotations(live) == [_URL_CITATION, _FILE_ANNOTATION], live
assert _posted(wire) == 1
store: Final = redis_store()
_assert_split(_cached_message(store, await_entry(store, identity)))
replayed: Final = _streamed(gateway, _chat_body(model, marker, stream=True))
assert _text(replayed) == _ANSWER, replayed
assert _provider_annotations(replayed) == [_FILE_ANNOTATION], replayed
assert _posted(wire) == 0

View file

@ -0,0 +1,390 @@
import json
import uuid
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, Scenario
from integration._support.openai_wire import chat_reply
from integration._support.pdf_document import (
LETTER,
chat_body,
chat_file,
messages_body,
pdf_data_url,
pdf_document,
responses_body,
responses_input_file,
)
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_API_KEY: Final = "synthetic-pdf-gate-key"
_ANSWER: Final = "The report covers revenue."
_FILENAME: Final = "report.pdf"
_PDF: Final = pdf_data_url((LETTER,))
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_MESSAGES: Final = TypeAdapter(list[JsonValue])
_COST_MAP_PATH: Final = Path(__file__).parents[3] / "litellm" / "model_prices_and_context_window_backup.json"
@dataclass(frozen=True, slots=True)
class _Entrypoint:
path: str
body: Callable[[str, str], Mapping[str, JsonValue]]
stream: bool = False
provider: str = "hosted_vllm"
_ENTRYPOINTS: Final = (
pytest.param(
_Entrypoint("/v1/chat/completions", lambda model, text: chat_body(model, [chat_file(_PDF, _FILENAME)], text)),
id="chat-completions",
),
pytest.param(
_Entrypoint(
"/v1/chat/completions",
lambda model, text: chat_body(model, [chat_file(_PDF, _FILENAME)], text, stream=True),
stream=True,
),
id="chat-completions-stream",
),
pytest.param(
_Entrypoint("/v1/messages", lambda model, text: messages_body(model, [pdf_document((LETTER,))], text)),
id="messages",
),
pytest.param(
_Entrypoint(
"/v1/responses",
lambda model, text: responses_body(model, [responses_input_file(_PDF, _FILENAME)], text),
provider="lm_studio",
),
id="responses-bridge",
),
)
def _azure_ai_chat_model_pdf_input(entry: JsonValue) -> bool | None:
match entry:
case {"litellm_provider": "azure_ai", "mode": "chat", "supports_pdf_input": bool() as supports}:
return supports
case _:
return None
def _azure_ai_chat_model(reads_pdfs: bool) -> str:
entries: Final = _JSON_OBJECT.validate_json(_COST_MAP_PATH.read_bytes())
matching: Final = sorted(
name for name, entry in entries.items() if _azure_ai_chat_model_pdf_input(entry) is reads_pdfs
)
assert matching, f"the cost map marks no azure_ai chat model with supports_pdf_input {reads_pdfs}"
return matching[0]
def _ollama_reply(backend: str) -> Reply:
return Reply(
body=json.dumps(
{
"model": backend,
"created_at": "2026-01-01T00:00:00Z",
"message": {"role": "assistant", "content": _ANSWER},
"done": True,
"done_reason": "stop",
"prompt_eval_count": 5,
"eval_count": 3,
}
).encode()
)
def _responder(backend: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
if request.target.endswith("/api/chat"):
return _ollama_reply(backend)
body: Final = _JSON_OBJECT.validate_json(request.body)
return chat_reply(f"chatcmpl-{uuid.uuid4().hex}", backend, _ANSWER, stream=body.get("stream") is True)
return respond
def _post(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> httpx.Response:
return gateway.request("POST", path, body, headers={"anthropic-version": "2023-06-01"})
def _post_streaming(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> httpx.Response:
with gateway.client.stream(
"POST", path, json=dict(body), headers={"Authorization": f"Bearer {gateway.key}"}
) as response:
response.read()
return response
def _send(gateway: Gateway, entrypoint: _Entrypoint, model: str, text: str) -> httpx.Response:
body: Final = entrypoint.body(model, text)
if entrypoint.stream:
return _post_streaming(gateway, entrypoint.path, body)
return _post(gateway, entrypoint.path, body)
def _error_message(response: httpx.Response) -> str:
match _JSON_OBJECT.validate_json(response.content):
case {"error": {"message": str() as message}}:
return message
case _:
raise AssertionError(response.text)
def _assert_refused_before_the_provider(response: httpx.Response, wire: Wire, backend: str) -> None:
assert response.status_code == 400, response.text
message: Final = _error_message(response)
assert "PDF" in message and backend in message, message
assert wire.drain() == ()
def _bodies(wire: Wire) -> tuple[dict[str, JsonValue], ...]:
return tuple(_JSON_OBJECT.validate_json(request.body) for request in wire.drain() if request.method == "POST")
def _only_body(wire: Wire) -> dict[str, JsonValue]:
bodies: Final = _bodies(wire)
assert len(bodies) == 1, bodies
return bodies[0]
def _assert_reaches_the_provider(gateway: Gateway, wire: Wire, model: str) -> None:
response: Final = _post(gateway, "/v1/chat/completions", chat_body(model, [], f"text only {uuid.uuid4().hex}"))
assert response.status_code == 200, response.text
assert len(_bodies(wire)) == 1
def _openai_compatible(
scenario: Scenario,
wire: Wire,
backend: str,
model_info: Mapping[str, JsonValue],
provider: str = "hosted_vllm",
**parameters: JsonValue,
) -> str:
return scenario.model(
model=f"{provider}/{backend}",
api_base=f"{wire.url}/v1",
api_key=_API_KEY,
model_info=model_info,
**parameters,
)
def _backend() -> str:
return f"pdf-gate-{uuid.uuid4().hex[:12]}"
@pytest.mark.parametrize("entrypoint", _ENTRYPOINTS)
def test_pdf_to_a_deployment_marked_unable_to_read_it_is_refused_before_the_provider(
gateway: Gateway, entrypoint: _Entrypoint
) -> None:
backend: Final = _backend()
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False}, entrypoint.provider)
response: Final = _send(gateway, entrypoint, model, f"Summarize the report {uuid.uuid4().hex}")
_assert_refused_before_the_provider(response, wire, backend)
_assert_reaches_the_provider(gateway, wire, model)
def _base_model_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
backend: Final = f"pdf-gate-{uuid.uuid4().hex[:12]}"
model: Final = scenario.model(
model=f"azure_ai/{backend}",
api_base=wire.url,
api_key=_API_KEY,
model_info={"base_model": _azure_ai_chat_model(reads_pdfs=False)},
)
return model, backend
def _litellm_params_base_model_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
backend: Final = _backend()
model: Final = scenario.model(
model=f"azure_ai/{backend}",
api_base=wire.url,
api_key=_API_KEY,
base_model=_azure_ai_chat_model(reads_pdfs=False),
)
return model, backend
def _cost_map_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
marked: Final = _azure_ai_chat_model(reads_pdfs=False)
return scenario.model(model=marked, api_base=wire.url, api_key=_API_KEY), marked.removeprefix("azure_ai/")
def _ollama_deployment(scenario: Scenario, wire: Wire) -> tuple[str, str]:
backend: Final = _backend()
return scenario.model(model=f"ollama_chat/{backend}", api_base=wire.url, api_key=None), backend
@pytest.mark.parametrize(
"deploy",
[
pytest.param(_base_model_deployment, id="model-info-base-model"),
pytest.param(_litellm_params_base_model_deployment, id="litellm-params-base-model"),
pytest.param(_cost_map_deployment, id="cost-map"),
pytest.param(_ollama_deployment, id="ollama"),
],
)
def test_pdf_to_a_model_its_declaration_marks_unable_is_refused_before_the_provider(
gateway: Gateway, deploy: Callable[[Scenario, Wire], tuple[str, str]]
) -> None:
with wire_server(_responder("scripted")) as wire, gateway.scenario() as scenario:
model, backend = deploy(scenario, wire)
response: Final = _post(
gateway,
"/v1/chat/completions",
chat_body(model, [chat_file(_PDF, _FILENAME)], f"Summarize the report {uuid.uuid4().hex}"),
)
_assert_refused_before_the_provider(response, wire, backend)
_assert_reaches_the_provider(gateway, wire, model)
def _pdf_turns(question: str) -> list[JsonValue]:
return [
{"role": "user", "content": [chat_file(_PDF, _FILENAME), {"type": "text", "text": question}]},
{"role": "assistant", "content": "It is a quarterly report."},
{"role": "user", "content": [chat_file(_PDF, "appendix.pdf")]},
{"role": "user", "content": "Who wrote it?"},
]
def _answer(response: httpx.Response) -> JsonValue:
match _JSON_OBJECT.validate_json(response.content):
case {"choices": [{"message": {"content": content}}]}:
return content
case _:
raise AssertionError(response.text)
def _turns_without_pdfs(question: str) -> list[JsonValue]:
return [
{"role": "user", "content": [{"type": "text", "text": question}]},
{"role": "assistant", "content": "It is a quarterly report."},
{"role": "user", "content": "Who wrote it?"},
]
@pytest.mark.parametrize(
("deployment_params", "request_params"),
[
pytest.param({"drop_params": True}, {}, id="deployment-drop-params"),
pytest.param({}, {"drop_params": True}, id="request-drop-params"),
],
)
def test_drop_params_sends_the_conversation_without_the_pdfs_a_model_cannot_read(
gateway: Gateway, deployment_params: Mapping[str, JsonValue], request_params: Mapping[str, JsonValue]
) -> None:
backend: Final = _backend()
question: Final = f"Summarize the report {uuid.uuid4().hex}"
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False}, **deployment_params)
response: Final = _post(
gateway,
"/v1/chat/completions",
{"model": model, "max_tokens": 64, "messages": _pdf_turns(question), **request_params},
)
assert response.status_code == 200, response.text
assert _answer(response) == _ANSWER
assert _only_body(wire)["messages"] == _turns_without_pdfs(question)
def test_drop_params_refuses_a_request_left_with_no_user_turn_once_the_pdf_is_dropped(gateway: Gateway) -> None:
backend: Final = _backend()
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False}, drop_params=True)
response: Final = _post(
gateway,
"/v1/chat/completions",
{
"model": model,
"max_tokens": 64,
"messages": [
{"role": "system", "content": f"Answer from the attachment {uuid.uuid4().hex}"},
{"role": "user", "content": [chat_file(_PDF, _FILENAME)]},
],
},
)
_assert_refused_before_the_provider(response, wire, backend)
_assert_reaches_the_provider(gateway, wire, model)
def _file_and_question(question: str) -> list[JsonValue]:
return [{"role": "user", "content": [chat_file(_PDF, _FILENAME), {"type": "text", "text": question}]}]
@pytest.mark.parametrize(
("model_info", "deployment_params", "keeps_pdf"),
[
pytest.param({}, {}, True, id="undeclared"),
pytest.param({}, {"drop_params": True}, False, id="undeclared-drop-params"),
pytest.param({"supports_pdf_input": True}, {"drop_params": True}, True, id="declared-drop-params"),
pytest.param(
{},
{"drop_params": True, "base_model": _azure_ai_chat_model(reads_pdfs=True)},
True,
id="base-model-reads-drop-params",
),
],
)
def test_azure_ai_model_outside_the_cost_map_gets_the_pdf_unless_drop_params_finds_no_declaration(
gateway: Gateway, model_info: Mapping[str, JsonValue], deployment_params: Mapping[str, JsonValue], keeps_pdf: bool
) -> None:
backend: Final = _backend()
question: Final = f"Summarize the report {uuid.uuid4().hex}"
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"azure_ai/{backend}",
api_base=wire.url,
api_key=_API_KEY,
model_info=model_info,
**deployment_params,
)
response: Final = _post(
gateway, "/v1/chat/completions", chat_body(model, [chat_file(_PDF, _FILENAME)], question)
)
assert response.status_code == 200, response.text
expected: Final[list[JsonValue]] = (
_file_and_question(question) if keeps_pdf else [{"role": "user", "content": question}]
)
assert _only_body(wire)["messages"] == expected
def _sibling_pdf_request(gateway: Gateway, model: str) -> httpx.Response:
return _post(
gateway,
"/v1/chat/completions",
chat_body(model, [chat_file(_PDF, _FILENAME)], f"Summarize the report {uuid.uuid4().hex}"),
)
def _forwarded_file_parts(bodies: Sequence[dict[str, JsonValue]]) -> tuple[JsonValue, ...]:
return tuple(_MESSAGES.validate_python(body["messages"])[0] for body in bodies)
def test_pdf_opt_out_on_one_deployment_leaves_its_sibling_on_the_same_model_reading_pdfs(gateway: Gateway) -> None:
backend: Final = _backend()
burst: Final = 6
with wire_server(_responder(backend)) as wire, gateway.scenario() as scenario:
sibling: Final = _openai_compatible(scenario, wire, backend, {})
marked: Final = _openai_compatible(scenario, wire, backend, {"supports_pdf_input": False})
with ThreadPoolExecutor(max_workers=burst) as pool:
responses: Final = tuple(pool.map(lambda _: _sibling_pdf_request(gateway, sibling), range(burst)))
assert [response.status_code for response in responses] == [200] * burst, [r.text for r in responses]
forwarded: Final = _forwarded_file_parts(_bodies(wire))
assert len(forwarded) == burst, forwarded
assert all(
_MESSAGES.validate_python(_JSON_OBJECT.validate_python(message)["content"])[0] == chat_file(_PDF, _FILENAME)
for message in forwarded
), forwarded
refused: Final = _sibling_pdf_request(gateway, marked)
_assert_refused_before_the_provider(refused, wire, backend)

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,9 @@ Green if `send_batched` drops every row. pydantic doubles in 12 of 203 files, fa
`tests/unit/<path>` mirrors `litellm/<path>`, so a changed file selects its tests by path, not a mapping
file. New unit tests go here
A new directory under `tests/unit` runs in CI only once a shard in `.github/workflows/test-unit.yml` names it.
`pytest tests/unit/test_assert_ci_coverage.py` checks that, so run it before you push
## Writing it so a human can read it
A class only when tests share an arrange

View file

@ -3013,3 +3013,53 @@ def test_a_finite_provider_cost_breakdown_with_non_string_keys_is_kept():
assert response.usage.cost == {1: 0.5}
_URL_CITATION: Final = {
"type": "url_citation",
"url_citation": {
"url": "https://example.com",
"title": "Example",
"content": "cited snippet",
"start_index": 0,
"end_index": 4,
},
}
_FILE_ANNOTATION: Final = {
"type": "file",
"file": {"hash": "abc123", "name": "report.pdf", "content": [{"type": "text", "text": "parsed page"}]},
}
def _message_with_annotations(annotations: list[dict[str, object]]) -> Message:
response: Final = {
"id": "gen-1",
"object": "chat.completion",
"created": 1712697600,
"model": "z-ai/glm-5.3",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "done", "annotations": annotations},
}
],
"usage": {"completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2},
}
return convert_to_model_response_object(
response_object=response, model_response_object=ModelResponse(), response_type="completion"
).choices[0].message
def test_annotations_typed_other_than_url_citation_move_to_provider_specific_fields() -> None:
untyped_annotation: Final = {"start_index": 0, "end_index": 4}
message: Final = _message_with_annotations([_URL_CITATION, untyped_annotation, _FILE_ANNOTATION])
assert message.annotations == [_URL_CITATION, untyped_annotation]
assert message.provider_specific_fields["annotations"] == [_FILE_ANNOTATION]
def test_url_citation_annotations_keep_provider_keys() -> None:
message: Final = _message_with_annotations([_URL_CITATION])
assert message.annotations == [_URL_CITATION]
assert "annotations" not in (message.provider_specific_fields or {})

View file

@ -24,6 +24,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.openai.common_utils import OpenAIError
from litellm.proxy.common_utils.openai_error_payload import openai_error_type
from litellm.types.utils import LlmProviders
@ -1015,6 +1016,138 @@ def test_azure_404_with_invalid_request_error_type_maps_to_not_found():
assert "Response with id 'resp_abc' not found." in excinfo.value.message
@pytest.mark.parametrize("provider", ["openai", "groq", "deepseek"])
@pytest.mark.parametrize(
"status_code, message, expected_exception",
[
(400, "Invalid 'temperature'", litellm.BadRequestError),
(401, "Invalid API key", litellm.AuthenticationError),
(404, "The model does not exist", litellm.NotFoundError),
],
)
def test_openai_invalid_request_error_respects_status_code(
provider: str, status_code: int, message: str, expected_exception: type[openai.APIStatusError]
) -> None:
error_message: Final = json.dumps({"error": {"message": message, "type": "invalid_request_error"}})
original_exception: Final = OpenAIError(status_code=status_code, message=error_message, headers={})
with pytest.raises(openai.APIError) as excinfo:
exception_type(
model="some-model",
original_exception=original_exception,
custom_llm_provider=provider,
)
assert type(excinfo.value) is expected_exception
assert excinfo.value.status_code == status_code
assert message in excinfo.value.message
@pytest.mark.parametrize("provider", ["openai", "groq", "deepseek"])
def test_openai_invalid_request_error_without_a_status_code_maps_to_bad_request(provider: str) -> None:
message: Final = "Missing required parameter: 'input'."
original_exception: Final = Exception(json.dumps({"error": {"message": message, "type": "invalid_request_error"}}))
with pytest.raises(openai.APIError) as excinfo:
exception_type(
model="some-model",
original_exception=original_exception,
custom_llm_provider=provider,
)
assert type(excinfo.value) is litellm.BadRequestError
assert message in excinfo.value.message
@pytest.mark.parametrize("provider", ["openai", "groq", "deepseek"])
@pytest.mark.parametrize("status_code", [403, 409, 422, 429, 500, 503])
def test_openai_invalid_request_error_keeps_bad_request_for_statuses_other_than_401_and_404(
provider: str, status_code: int
) -> None:
message: Final = "Cannot cancel a batch that is already completed."
error_message: Final = json.dumps({"error": {"message": message, "type": "invalid_request_error"}})
original_exception: Final = OpenAIError(status_code=status_code, message=error_message, headers={})
with pytest.raises(openai.APIError) as excinfo:
exception_type(
model="some-model",
original_exception=original_exception,
custom_llm_provider=provider,
)
assert type(excinfo.value) is litellm.BadRequestError
assert message in excinfo.value.message
def _openai_sdk_error(
sdk_error: type[openai.APIStatusError], status_code: int, provider_error: dict[str, str | None]
) -> openai.APIStatusError:
request: Final = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
return sdk_error(
f"Error code: {status_code} - {{'error': {provider_error}}}",
response=httpx.Response(status_code, request=request),
body=provider_error,
)
@pytest.mark.parametrize(
"sdk_error, status_code, expected_exception, param, code",
[
(openai.AuthenticationError, 401, litellm.AuthenticationError, "organization", "mismatched_organization"),
(openai.NotFoundError, 404, litellm.NotFoundError, "id", None),
],
)
def test_openai_invalid_request_error_401_and_404_keep_the_provider_error_fields(
sdk_error: type[openai.APIStatusError],
status_code: int,
expected_exception: type[openai.APIStatusError],
param: str,
code: str | None,
) -> None:
provider_error: Final = {
"message": "The request was rejected.",
"type": "invalid_request_error",
"param": param,
"code": code,
}
with pytest.raises(openai.APIError) as excinfo:
exception_type(
model="gpt-5-mini",
original_exception=_openai_sdk_error(sdk_error, status_code, provider_error),
custom_llm_provider="openai",
)
assert type(excinfo.value) is expected_exception
assert (excinfo.value.status_code, excinfo.value.type, excinfo.value.param, excinfo.value.code) == (
status_code,
"invalid_request_error",
param,
code,
)
assert excinfo.value.body == provider_error
assert openai_error_type(excinfo.value, status_code) == "invalid_request_error"
assert excinfo.value.message.count(expected_exception.__name__) == 1
def test_openai_incorrect_api_key_keeps_the_proxy_authentication_error_type() -> None:
provider_error: Final = {
"message": "Incorrect API key provided: sk-litellmqa.",
"type": "invalid_request_error",
"param": None,
"code": "invalid_api_key",
}
with pytest.raises(litellm.AuthenticationError) as excinfo:
exception_type(
model="gpt-5-mini",
original_exception=_openai_sdk_error(openai.AuthenticationError, 401, provider_error),
custom_llm_provider="openai",
)
assert openai_error_type(excinfo.value, 401) == "authentication_error"
class _UpstreamHTTPError(Exception):
def __init__(self, status_code: int) -> None:
super().__init__("upstream failure")

View file

@ -795,9 +795,12 @@ async def test_ahealth_check_keeps_the_noul_probe_for_other_decisions_providers(
("us.anthropic.claude-haiku-4-5-20251001-v1:0", "bedrock", None),
("claude-haiku-4-5", "anthropic", None),
("anthropic.claude-haiku-4-5", None, None),
("stt-rt-v5", "soniox", "realtime"),
("stt-rt-preview", "soniox", "realtime"),
("stt-async-v5", "soniox", None),
),
)
def test_native_health_check_mode_is_messages_only_for_mantle_claude(
def test_native_health_check_mode_routes_mantle_claude_to_messages_and_soniox_realtime_models_to_realtime(
model: str, custom_llm_provider: str | None, expected: str | None
) -> None:
assert native_health_check_mode(model=model, custom_llm_provider=custom_llm_provider) == expected

View file

@ -24,6 +24,7 @@ from litellm.litellm_core_utils.realtime_streaming import (
client_sent_openai_beta_realtime_header,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.realtime.transcription_protocol import RealtimeTranscriptionProtocolError
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig
from litellm.llms.xai.realtime.transformation import XAIRealtimeNormalizer
from litellm.llms.xai.realtime.usage_meter import XAIRealtimeUsageMeter
@ -827,9 +828,7 @@ async def test_usage_meter_snapshot_stored_in_model_call_details():
backend_ws.send = AsyncMock()
backend_ws.recv = AsyncMock(
side_effect=[
json.dumps(
{"type": "session.created", "session": {"turn_detection": {"type": None}}}
).encode(),
json.dumps({"type": "session.created", "session": {"turn_detection": {"type": None}}}).encode(),
json.dumps(
{"type": "response.output_audio.delta", "delta": base64.b64encode(b"x" * 24000).decode()}
).encode(),
@ -3187,6 +3186,7 @@ async def test_session_close_flushes_unbilled_transcription_usage():
provider_config: Final = MagicMock()
provider_config.transform_realtime_response = MagicMock(return_value=transcript_frame)
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=usage)
provider_config.session_refusal = MagicMock(return_value=None)
streaming: Final = RealTimeStreaming(
client_ws,
@ -3320,10 +3320,14 @@ def _backend_ws_closing_with(*frames: bytes | Exception) -> MagicMock:
return backend_ws
def _relay_session(client_ws: MagicMock, backend_ws: MagicMock) -> _RelaySession:
def _relay_session(
client_ws: MagicMock, backend_ws: MagicMock, provider_config: BaseRealtimeConfig | None = None
) -> _RelaySession:
logging: Final = _RecordingLogging()
worker: Final = _InlineLoggingWorker()
streaming: Final = RealTimeStreaming(client_ws, backend_ws, logging, model="gpt-realtime", logging_worker=worker)
streaming: Final = RealTimeStreaming(
client_ws, backend_ws, logging, provider_config=provider_config, model="gpt-realtime", logging_worker=worker
)
return _RelaySession(streaming=streaming, logging=logging, worker=worker)
@ -3408,6 +3412,103 @@ async def test_upstream_refusal_before_any_frame_logs_a_failure_not_a_success():
assert session.logging.logged_sessions == ()
class _RefusingRealtimeConfig(_PassthroughRealtimeConfig):
def __init__(self, refusal: Exception | None) -> None:
self._refusal: Final = refusal
def requires_session_configuration(self) -> bool:
return True
def session_refusal(self) -> Exception | None:
return self._refusal
@pytest.mark.asyncio
async def test_a_session_the_provider_refused_in_band_logs_a_failure_even_after_a_normal_close():
refusal: Final = ValueError("Soniox realtime error 401: Invalid API key.")
session: Final = _relay_session(
_client_ws_that_never_sends(),
_backend_ws_closing_with(
json.dumps({"type": "error", "error": {"message": str(refusal)}}).encode(),
ConnectionClosed(Close(1000, ""), None),
),
_RefusingRealtimeConfig(refusal),
)
await session.run()
assert session.logging.logged_failures == (refusal,)
assert session.logging.logged_sessions == ()
@pytest.mark.asyncio
async def test_a_provider_session_without_a_refusal_logs_a_success_after_a_normal_close():
session: Final = _relay_session(
_client_ws_that_never_sends(),
_backend_ws_closing_with(
json.dumps({"type": "session.created", "session": {"id": "sess_1"}}).encode(),
ConnectionClosed(Close(1000, ""), None),
),
_RefusingRealtimeConfig(None),
)
await session.run()
assert session.logging.logged_failures == ()
assert len(session.logging.logged_sessions) == 1
class _TranscriptionConfigRejectingSessionUpdates(_PassthroughRealtimeConfig):
def __init__(self, *, defers_setup: bool) -> None:
self._defers_setup: Final = defers_setup
def requires_session_configuration(self) -> bool:
return not self._defers_setup
def transform_realtime_request(
self, message: str, model: str, session_configuration_request: str | None = None
) -> Sequence[str | bytes]:
if json.loads(message).get("type") == "session.update":
raise RealtimeTranscriptionProtocolError("requires pcm16 input audio")
return (message,)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"defers_setup,client_frames_read,audio_reaches_backend",
((True, 1, False), (False, 3, True)),
ids=["deferred_setup_session_ends", "eager_setup_session_continues"],
)
async def test_a_rejected_transcription_session_update_sends_an_error_and_ends_only_a_deferred_setup_session(
defers_setup: bool, client_frames_read: int, audio_reaches_backend: bool
):
append: Final = json.dumps({"type": "input_audio_buffer.append", "audio": "AAE="})
client_ws: Final = MagicMock()
client_ws.headers = {}
client_ws.receive_text = AsyncMock(
side_effect=[json.dumps({"type": "session.update", "session": {"type": "transcription"}}), append, RuntimeError]
)
client_ws.send_text = AsyncMock()
backend_ws: Final = MagicMock()
backend_ws.send = AsyncMock()
streaming: Final = RealTimeStreaming(
client_ws,
backend_ws,
_RecordingLogging(),
provider_config=_TranscriptionConfigRejectingSessionUpdates(defers_setup=defers_setup),
model="chirp_3",
logging_worker=_InlineLoggingWorker(),
)
await asyncio.wait_for(streaming.client_ack_messages(), timeout=2)
assert _error_events_sent_to(client_ws) == [
{"type": "error", "error": {"type": "invalid_request_error", "message": "requires pcm16 input audio"}}
]
assert client_ws.receive_text.await_count == client_frames_read
assert (append in [call.args[0] for call in backend_ws.send.await_args_list]) is audio_reaches_backend
@pytest.mark.asyncio
async def test_upstream_refusal_after_a_synthetic_session_created_still_logs_a_failure():
"""LIT-6973: deferred Gemini Live setup stores a synthetic ``session.created`` before
@ -4056,9 +4157,7 @@ _XAI_TYPED_VAD_SESSION: Final = {
pytest.param(({"type": "realtime", "instructions": "hi"},), id="no-turn-detection"),
pytest.param(({"type": "realtime", "audio": {"input": {"turn_detection": None}}},), id="push-to-talk"),
pytest.param((_XAI_TYPED_VAD_SESSION,), id="typed-server-vad"),
pytest.param(
({"type": "realtime", "instructions": "hi"}, _XAI_TYPED_VAD_SESSION), id="later-typed-server-vad"
),
pytest.param(({"type": "realtime", "instructions": "hi"}, _XAI_TYPED_VAD_SESSION), id="later-typed-server-vad"),
],
)
async def test_xai_session_update_gate_leaves_out_the_vad_type(
@ -4070,9 +4169,9 @@ async def test_xai_session_update_gate_leaves_out_the_vad_type(
monkeypatch, *client_sessions, event_normalizer=XAIRealtimeNormalizer()
)
assert [session["audio"]["input"]["turn_detection"] for session in forwarded] == [
{"create_response": False}
] * (len(client_sessions) + 1)
assert [session["audio"]["input"]["turn_detection"] for session in forwarded] == [{"create_response": False}] * (
len(client_sessions) + 1
)
@pytest.mark.asyncio
@ -4206,7 +4305,9 @@ async def test_client_never_sees_the_error_caused_by_the_guardrail_cancel(monkey
await RealTimeStreaming(client_ws, backend_ws, logging_obj).backend_to_client_send_messages()
client_errors: Final = [
event["error"] for call in client_ws.send_text.await_args_list if (event := json.loads(call.args[0]))["type"] == "error"
event["error"]
for call in client_ws.send_text.await_args_list
if (event := json.loads(call.args[0]))["type"] == "error"
]
assert [(error["code"], error.get("event_id")) for error in client_errors] == [
("content_policy_violation", None),

View file

@ -5286,6 +5286,40 @@ def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta():
assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA
@pytest.mark.parametrize(
("title", "expected_filename"),
[(None, "document.pdf"), ("Quarterly report", "Quarterly report")],
)
def test_user_pdf_document_reaches_openai_compatible_providers_as_file_part(
title: str | None, expected_filename: str
) -> None:
document: Final = _base64_pdf_block() | ({"title": title} if title else {})
request_messages: Final = _run_chat_completions_pipeline(
[
AnthropicMessagesUserMessageParam(
role="user",
content=[document, {"type": "text", "text": "What is the codeword?"}],
)
]
)
assert request_messages == [
{
"role": "user",
"content": [
{
"type": "file",
"file": {
"filename": expected_filename,
"file_data": f"data:application/pdf;base64,{TOOL_RESULT_PDF_B64}",
},
},
{"type": "text", "text": "What is the codeword?"},
],
}
]
_CHAT_BRIDGE_MODEL: Final = "azure/gpt-5.6"
_CHAT_BRIDGE_API_BASE: Final = "https://tool-result-text.openai.azure.com"
_CHAT_BRIDGE_URL: Final = r".*tool-result-text\.openai\.azure\.com/openai/deployments/gpt-5\.6/chat/completions.*"

View file

@ -1101,6 +1101,58 @@ def test_map_azure_model_group(model_group_header, expected_model):
assert config._map_azure_model_group(model_group_header) == expected_model
def test_azure_ai_sends_file_parts_instead_of_flattening_them_to_text() -> None:
file_part: Final = {
"type": "file",
"file": {"filename": "document.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQK"},
}
content: Final = [file_part, {"type": "text", "text": "What is the codeword?"}]
request: Final = AzureAIStudioConfig().transform_request(
model="Kimi-K2.6",
messages=[{"role": "user", "content": content}],
optional_params={},
litellm_params={},
headers={},
)
assert request["messages"] == [{"role": "user", "content": content}]
def test_azure_ai_passes_dict_message_content_through_for_the_provider_to_judge() -> None:
content: Final = {"type": "text", "text": "Say hi"}
request: Final = AzureAIStudioConfig().transform_request(
model="gpt-5.6-sol",
messages=[{"role": "user", "content": content}],
optional_params={},
litellm_params={},
headers={},
)
assert request["messages"] == [{"role": "user", "content": content}]
@pytest.mark.parametrize(
"file",
[
{"filename": "notes.txt", "file_data": "data:text/plain;base64,aGVsbG8="},
{"file_id": "file-abc"},
],
ids=["text_data_url", "file_id_only"],
)
def test_azure_ai_flattens_a_non_pdf_file_part_to_its_text_as_before(file: dict[str, str]) -> None:
request: Final = AzureAIStudioConfig().transform_request(
model="Llama-3.3-70B-Instruct",
messages=[{"role": "user", "content": [{"type": "text", "text": "summarize"}, {"type": "file", "file": file}]}],
optional_params={},
litellm_params={},
headers={},
)
assert request["messages"] == [{"role": "user", "content": "summarize"}]
def test_translate_developer_role_hoists_a_later_developer_message_for_a_model_that_rejects_it(
declare_mid_conversation_system: Callable[[str, bool], None],
) -> None:

View file

@ -4911,6 +4911,160 @@ async def test_async_realtime_bridges_a_transcription_session_through_the_provid
assert [bytes(request.audio) for request in speech_client.requests[1:]] == [b"\x00\x01" * 800, b"\x00\x01" * 800]
class _FakeSonioxSocket:
def __init__(self) -> None:
self.sent: Final[list[str | bytes]] = []
self._responses: Final[asyncio.Queue[str]] = asyncio.Queue()
async def __aenter__(self) -> "_FakeSonioxSocket":
return self
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
return None
async def send(self, message: str | bytes) -> None:
self.sent.append(message)
if message == '{"type":"finalize"}':
tokens = [{"text": "Hello", "is_final": True}, {"text": "<fin>", "is_final": True}]
await self._responses.put(json.dumps({"tokens": tokens, "total_audio_proc_ms": 1200}))
async def recv(self, decode: bool | None = None) -> str:
return await self._responses.get()
async def close(self) -> None:
return None
@pytest.mark.asyncio
async def test_async_realtime_bridges_a_soniox_transcription_through_the_wrapped_websocket():
import websockets.exceptions # noqa: F401 # binds the submodule so async_realtime's except clause resolves, as in the proxy process
from litellm.llms.soniox.realtime.transformation import SonioxRealtimeBackend, SonioxRealtimeConfig
upstream = _FakeSonioxSocket()
wrapped: list[object] = []
provider_config = SonioxRealtimeConfig(
wrap=lambda backend: wrapped.append(backend) or SonioxRealtimeBackend(backend)
)
audio = b"\x00\x01" * 800
client_ws = _ScriptedClientWebSocket(
[
json.dumps(
{
"type": "session.update",
"session": {
"type": "transcription",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": 16000},
"transcription": {"model": "stt-rt-v5", "language": "en"},
"turn_detection": None,
}
},
},
}
),
json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(audio).decode()}),
json.dumps({"type": "input_audio_buffer.commit"}),
],
last_event_type="conversation.item.input_audio_transcription.completed",
)
logging_obj = Mock()
logging_obj.litellm_trace_id = "trace_1"
logging_obj.model_call_details = {}
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
handler = BaseLLMHTTPHandler()
with patch.object(handler, "_open_realtime_backend_ws", AsyncMock(return_value=upstream)) as dial:
await handler.async_realtime(
model="stt-rt-v5",
websocket=client_ws,
logging_obj=logging_obj,
provider_config=provider_config,
headers={},
api_key="soniox-key",
query_params={"model": "stt-rt-v5", "intent": "transcription"},
)
assert dial.await_args.args[1:3] == (
"wss://stt-rt.soniox.com/transcribe-websocket",
{"Authorization": "Bearer soniox-key"},
)
assert wrapped == [upstream]
assert json.loads(upstream.sent[0]) == {
"model": "stt-rt-v5",
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"num_channels": 1,
"enable_endpoint_detection": False,
"language_hints": ["en"],
}
assert upstream.sent[1:] == [audio, '{"type":"finalize"}']
events = client_ws.sent_events()
assert [event["type"] for event in events] == [
"session.created",
"session.updated",
"input_audio_buffer.speech_started",
"conversation.item.input_audio_transcription.delta",
"conversation.item.input_audio_transcription.completed",
]
assert events[-1]["transcript"] == "Hello"
assert events[-1]["usage"] == {"type": "duration", "seconds": 1.2}
@pytest.mark.asyncio
async def test_async_realtime_soniox_rejects_a_bad_session_update_and_still_transcribes_audio_sent_without_one():
import websockets.exceptions # noqa: F401 # binds the submodule so async_realtime's except clause resolves, as in the proxy process
from litellm.llms.soniox.realtime.transformation import SonioxRealtimeConfig
upstream = _FakeSonioxSocket()
audio = b"\x00\x01" * 800
client_ws = _ScriptedClientWebSocket(
[
json.dumps(
{
"type": "session.update",
"session": {"type": "transcription", "audio": {"input": {"format": {"type": "audio/pcmu"}}}},
}
),
json.dumps({"type": "input_audio_buffer.append", "audio": base64.b64encode(audio).decode()}),
json.dumps({"type": "input_audio_buffer.commit"}),
],
last_event_type="conversation.item.input_audio_transcription.completed",
)
logging_obj = Mock()
logging_obj.litellm_trace_id = "trace_1"
logging_obj.model_call_details = {}
logging_obj.dispatch_success_handlers = AsyncMock()
logging_obj.dispatch_failure_handlers = AsyncMock()
handler = BaseLLMHTTPHandler()
with patch.object(handler, "_open_realtime_backend_ws", AsyncMock(return_value=upstream)):
await handler.async_realtime(
model="stt-rt-v5",
websocket=client_ws,
logging_obj=logging_obj,
provider_config=SonioxRealtimeConfig(),
headers={},
api_key="soniox-key",
query_params={"model": "stt-rt-v5", "intent": "transcription"},
)
assert json.loads(upstream.sent[0])["sample_rate"] == 24_000
assert upstream.sent[1:] == [audio, '{"type":"finalize"}']
events = client_ws.sent_events()
errors = [event for event in events if event["type"] == "error"]
assert errors == [
{
"type": "error",
"error": {"type": "invalid_request_error", "message": "Soniox realtime requires pcm16 input audio"},
}
]
assert events[-1]["transcript"] == "Hello"
@pytest.mark.asyncio
async def test_responses_agentic_followup_does_not_repeat_request_params_from_plan_kwargs(monkeypatch):
"""A plan whose kwargs repeat a request param must not crash the Responses follow-up with a duplicate keyword"""

View file

@ -8,6 +8,7 @@ get_model_response_iterator, and get_error_class.
"""
import json
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
@ -512,6 +513,20 @@ class TestTransformMessages:
assert result[0]["content"] == "look"
assert "file-456" in result[0]["attachments"]
def test_list_content_with_pdf_file_part_uploads_it_as_an_attachment(self) -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQK"
content: Final = [
{"type": "text", "text": "summarize"},
{"type": "file", "file": {"filename": "report.pdf", "file_data": pdf_data_url}},
]
with patch.object(self.config, "_upload_image", return_value="file-789") as upload:
result: Final = self.config._transform_messages([
{"role": "user", "content": content}
])
upload.assert_called_once_with(pdf_data_url)
assert result[0]["content"] == "summarize"
assert result[0]["attachments"] == ["file-789"]
class TestTransformRequest:
def setup_method(self):

View file

@ -1,4 +1,5 @@
import datetime
from typing import Final
import httpx
import pytest
import json
@ -2164,3 +2165,32 @@ class TestOpenAIPrefixMaxTokensKey:
chat_request = _oci_chat_request("openai.gpt-5", {"max_tokens": 64})
assert chat_request["maxCompletionTokens"] == 64
assert "maxTokens" not in chat_request
def test_transform_request_sends_a_pdf_file_part_as_an_image_data_url() -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQK"
transformed_request: Final = OCIChatConfig().transform_request(
model="google.gemini-2.5-flash",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize this."},
{"type": "file", "file": {"filename": "report.pdf", "file_data": pdf_data_url}},
],
}
],
optional_params={"oci_compartment_id": TEST_COMPARTMENT_ID},
litellm_params={},
headers={},
)
assert transformed_request["chatRequest"]["messages"] == [
{
"role": "USER",
"content": [
{"type": "TEXT", "text": "Summarize this."},
{"type": "IMAGE", "imageUrl": {"url": pdf_data_url}},
],
}
]

View file

View file

@ -0,0 +1,482 @@
import hashlib
import json
import re
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Final
from unittest.mock import MagicMock
import httpx
import openai
import pytest
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
from botocore.exceptions import CredentialRetrievalError
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.opensearch.vector_stores.transformation import OpenSearchVectorStoreConfig
from litellm.types.llms.bedrock import AwsAuthParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import EmbeddingResponse
from litellm.types.vector_stores import VectorStoreSearchResponse
from litellm.vector_stores import main as vector_stores
ACCESS_KEY: Final = "AKIDOPENSEARCHTEST"
SECRET_KEY: Final = "opensearch-test-secret"
QUERY: Final = "How many days a year can I work abroad?"
QUERY_VECTOR: Final = [0.1, 0.2, 0.3]
CREDENTIAL_SOURCE_OUTPUT: Final = "credential-process-stderr-detail"
SERVERLESS: Final = "https://abc123.us-east-1.aoss.amazonaws.com"
BASE_PARAMS: Final = {
"api_base": SERVERLESS,
"aws_access_key_id": ACCESS_KEY,
"aws_secret_access_key": SECRET_KEY,
"litellm_embedding_model": "embedding-alias",
}
_AUTHORIZATION: Final = re.compile(
r"\AAWS4-HMAC-SHA256 Credential=(?P<key>[^/]+)/(?P<date>\d{8})/(?P<region>[^/]+)/(?P<service>[^/]+)/aws4_request, "
r"SignedHeaders=(?P<signed>[^,]+), Signature=(?P<signature>[0-9a-f]{64})\Z"
)
class RecordingEmbeddingExecutor:
def __init__(self) -> None:
self.call: Final = MagicMock(return_value=EmbeddingResponse(data=[{"embedding": QUERY_VECTOR}]))
def embed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse:
return self.call(model, query, configuration)
async def aembed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse:
return self.call(model, query, configuration)
def _signature_scope(request: httpx.Request) -> tuple[str, str]:
authorization: Final = _AUTHORIZATION.match(request.headers["authorization"])
assert authorization is not None, request.headers["authorization"]
signed_names: Final = authorization["signed"].split(";")
assert {"host", "x-amz-content-sha256", "x-amz-date"} <= set(signed_names), signed_names
assert request.headers["x-amz-content-sha256"] == hashlib.sha256(request.content).hexdigest()
signed_request: Final = AWSRequest(
method=request.method,
url=str(request.url),
data=request.content,
headers={name: request.headers[name] for name in signed_names},
)
signed_request.context["timestamp"] = request.headers["x-amz-date"]
signer: Final = SigV4Auth(Credentials(ACCESS_KEY, SECRET_KEY), authorization["service"], authorization["region"])
expected: Final = signer.signature(
signer.string_to_sign(signed_request, signer.canonical_request(signed_request)), signed_request
)
assert authorization["key"] == ACCESS_KEY
assert authorization["signature"] == expected, "SigV4 signature does not cover the request that was sent"
return authorization["service"], authorization["region"]
def _sent(respond: MagicMock) -> tuple[httpx.Request, ...]:
return tuple(call.args[0] for call in respond.call_args_list)
def _opensearch_hits(text_field: str, embedding_field: str) -> Mapping[str, object]:
return {
"took": 4,
"timed_out": False,
"hits": {
"total": {"value": 2, "relation": "eq"},
"max_score": 0.581,
"hits": [
{
"_index": "litellm-qa-docs",
"_id": "1%3A0%3Aw6ftW6EB",
"_score": 0.581,
"_source": {
text_field: "Employees may work abroad for at most 37 calendar days per year.",
embedding_field: [0.5, 0.5, 0.5],
"embedding_ada002": [0.25, 0.75],
"filename": "hr-remote-work-policy.md",
"category": "hr",
"revision": 7,
"published": True,
"tags": ["hr", "travel"],
"owner": {"team": "people-ops"},
"reviewed_by": None,
},
},
{"_index": "litellm-qa-docs", "_id": "doc-2", "_score": 0.42, "_source": {"category": "facilities"}},
],
},
}
EXPECTED_RESULTS: Final = [
{
"score": 0.581,
"content": [{"type": "text", "text": "Employees may work abroad for at most 37 calendar days per year."}],
"file_id": "1%3A0%3Aw6ftW6EB",
"filename": "hr-remote-work-policy.md",
"attributes": {"filename": "hr-remote-work-policy.md", "category": "hr", "revision": 7, "published": True},
},
{"score": 0.42, "content": [], "file_id": "doc-2", "filename": None, "attributes": {"category": "facilities"}},
]
async def _search(
asynchronous: bool,
respond: Callable[[httpx.Request], httpx.Response],
executor: RecordingEmbeddingExecutor,
kwargs: Mapping[str, object],
) -> VectorStoreSearchResponse:
request_kwargs: Final = {
**BASE_PARAMS,
"vector_store_id": "litellm-qa-docs",
"query": QUERY,
"custom_llm_provider": "opensearch",
"_direct_vector_store_embedding_executor": executor,
**kwargs,
}
if asynchronous:
return await litellm.vector_stores.asearch(
**request_kwargs, client=AsyncHTTPHandler(transport=httpx.MockTransport(respond))
)
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
return litellm.vector_stores.search(**request_kwargs, client=HTTPHandler(client=transport))
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize(
"fields",
[{}, {"opensearch_embedding_field": "passage_vector", "opensearch_text_field": "passage"}],
)
@pytest.mark.parametrize(
"api_base,region_params,service,region",
[
(SERVERLESS, {}, "aoss", "us-east-1"),
(SERVERLESS, {"aws_region_name": "us-west-2"}, "aoss", "us-east-1"),
("https://search-docs-xyz.eu-west-1.es.amazonaws.com/", {}, "es", "eu-west-1"),
("https://vpc-docs-xyz.cn-north-1.es.amazonaws.com.cn", {}, "es", "cn-north-1"),
("https://abc123.us-gov-west-1.aoss-fips.amazonaws.com", {}, "aoss", "us-gov-west-1"),
("https://abc123.aoss.eu-central-1.on.aws", {}, "aoss", "eu-central-1"),
("https://search-docs-xyz.aos.ap-southeast-2.on.aws", {}, "es", "ap-southeast-2"),
("https://search.docs.example", {"aws_region_name": "ap-south-1"}, "es", "ap-south-1"),
],
)
@pytest.mark.asyncio
async def test_search_sends_a_signed_knn_query_and_returns_an_openai_results_page(
asynchronous: bool,
fields: Mapping[str, str],
api_base: str,
region_params: Mapping[str, str],
service: str,
region: str,
) -> None:
executor: Final = RecordingEmbeddingExecutor()
embedding_field: Final = fields.get("opensearch_embedding_field", "embedding")
text_field: Final = fields.get("opensearch_text_field", "text")
respond: Final = MagicMock(return_value=httpx.Response(200, json=_opensearch_hits(text_field, embedding_field)))
response: Final = await _search(
asynchronous,
respond,
executor,
{**fields, **region_params, "api_base": api_base, "max_num_results": 3},
)
assert response == {"object": "vector_store.search_results.page", "search_query": QUERY, "data": EXPECTED_RESULTS}
sent: Final = _sent(respond)
assert len(sent) == 1
assert str(sent[0].url) == f"{api_base.rstrip('/')}/litellm-qa-docs/_search"
assert json.loads(sent[0].content) == {
"size": 3,
"query": {"knn": {embedding_field: {"vector": QUERY_VECTOR, "k": 3}}},
"_source": {"excludes": [embedding_field]},
}
assert _signature_scope(sent[0]) == (service, region)
executor.call.assert_called_once_with("embedding-alias", QUERY, {})
@pytest.mark.asyncio
async def test_search_defaults_to_ten_results_like_openai() -> None:
respond: Final = MagicMock(return_value=httpx.Response(200, json={"hits": {"hits": []}}))
response: Final = await _search(True, respond, RecordingEmbeddingExecutor(), {})
assert response == {"object": "vector_store.search_results.page", "search_query": QUERY, "data": []}
body: Final = json.loads(_sent(respond)[0].content)
assert (body["size"], body["query"]["knn"]["embedding"]["k"]) == (10, 10)
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.asyncio
async def test_a_list_query_is_embedded_and_echoed_as_one_string(asynchronous: bool) -> None:
executor: Final = RecordingEmbeddingExecutor()
respond: Final = MagicMock(return_value=httpx.Response(200, json={"hits": {"hits": []}}))
response: Final = await _search(asynchronous, respond, executor, {"query": ["remote work", "abroad"]})
assert response["search_query"] == "remote work abroad"
executor.call.assert_called_once_with("embedding-alias", "remote work abroad", {})
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("status", [403, 404, 429, 500])
@pytest.mark.asyncio
async def test_opensearch_error_keeps_its_status(asynchronous: bool, status: int) -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(status, json={"status": status, "error": {"reason": "upstream refused", "type": "x"}})
with pytest.raises(openai.APIStatusError) as error:
await _search(asynchronous, respond, RecordingEmbeddingExecutor(), {})
assert error.value.status_code == status
@pytest.mark.parametrize(
"overrides,message",
[
({"filters": {"type": "eq", "key": "category", "value": "hr"}}, "filters"),
({"ranking_options": {"score_threshold": 0.5}}, "ranking_options"),
({"rewrite_query": True}, "rewrite_query"),
({"max_num_results": 0}, "max_num_results"),
({"max_num_results": 51}, "max_num_results"),
({"max_num_results": "ten"}, "Invalid vector store search parameters"),
({"litellm_embedding_model": None}, "litellm_embedding_model"),
({"opensearch_text_feild": "passage"}, "opensearch_text_feild"),
({"opensearch_text_field": 7}, "opensearch_text_field"),
({"opensearch_index_name": ["docs"]}, "opensearch_index_name"),
({"extra_body": {"min_score": 0.5}}, "extra_body"),
({"api_base": None}, "api_base"),
({"api_base": "abc123.us-east-1.aoss.amazonaws.com"}, "api_base"),
({"api_base": "https://123456789012.aoss.us-east-1.on.aws"}, "per-account endpoint"),
({"api_key": "opensearch-basic-auth"}, "api_key"),
],
)
@pytest.mark.asyncio
async def test_unsupported_search_is_rejected_before_embedding_or_any_request(
overrides: Mapping[str, object], message: str
) -> None:
executor: Final = RecordingEmbeddingExecutor()
respond: Final = MagicMock(return_value=httpx.Response(200, json={"hits": {"hits": []}}))
with pytest.raises(litellm.BadRequestError, match=message) as error:
await _search(True, respond, executor, overrides)
assert error.value.status_code == 400
respond.assert_not_called()
executor.call.assert_not_called()
_AMBIENT_AWS_ENV: Final = (
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_PROFILE_NAME",
"AWS_ROLE_NAME",
"AWS_ROLE_ARN",
"AWS_SESSION_NAME",
"AWS_WEB_IDENTITY_TOKEN",
"AWS_WEB_IDENTITY_TOKEN_FILE",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
)
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize(
"credentials,env",
[
pytest.param({"aws_session_name": "opensearch-no-credentials"}, {}, id="none-anywhere"),
pytest.param({"aws_profile_name": "opensearch-missing-profile"}, {}, id="missing-profile"),
pytest.param({}, {"AWS_ACCESS_KEY_ID": "AKIDPARTIAL"}, id="key-without-secret"),
pytest.param(
{"aws_access_key_id": "AKIDSTOREONLY"},
{"AWS_ACCESS_KEY_ID": "AKIDAMBIENT", "AWS_SECRET_ACCESS_KEY": "ambient-secret"},
id="store-key-without-secret-beside-ambient-credentials",
),
pytest.param(
{"aws_secret_access_key": "store-secret-only"},
{"AWS_ACCESS_KEY_ID": "AKIDAMBIENT", "AWS_SECRET_ACCESS_KEY": "ambient-secret"},
id="store-secret-without-key-beside-ambient-credentials",
),
],
)
@pytest.mark.asyncio
async def test_missing_aws_credentials_are_a_401_before_embedding_or_any_request(
asynchronous: bool,
credentials: Mapping[str, str],
env: Mapping[str, str],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
for name in _AMBIENT_AWS_ENV:
monkeypatch.delenv(name, raising=False)
for name in ("AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "BOTO_CONFIG"):
config_file: Path = tmp_path / name
config_file.write_text("")
monkeypatch.setenv(name, str(config_file))
monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
for name, value in env.items():
monkeypatch.setenv(name, value)
executor: Final = RecordingEmbeddingExecutor()
respond: Final = MagicMock(return_value=httpx.Response(200, json={"hits": {"hits": []}}))
with pytest.raises(litellm.AuthenticationError, match="No usable AWS credentials") as error:
await _search(
asynchronous,
respond,
executor,
{"aws_access_key_id": None, "aws_secret_access_key": None, **credentials},
)
assert error.value.status_code == 401
respond.assert_not_called()
executor.call.assert_not_called()
class _FailingCredentialSource(BaseAWSLLM):
def resolve_credentials(self, auth_params: AwsAuthParams, aws_region_name: str | None) -> Credentials:
raise CredentialRetrievalError(provider="container-role", error_msg=CREDENTIAL_SOURCE_OUTPUT)
def test_failing_credential_source_is_a_401_that_keeps_its_output_out_of_the_reply() -> None:
config: Final = OpenSearchVectorStoreConfig(aws=_FailingCredentialSource())
with pytest.raises(litellm.AuthenticationError, match="the AWS credential source failed") as error:
config.validate_environment({}, GenericLiteLLMParams(api_base=SERVERLESS))
assert error.value.status_code == 401
assert CREDENTIAL_SOURCE_OUTPUT not in str(error.value)
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.asyncio
async def test_opensearch_index_name_picks_the_index_so_the_store_id_can_differ(asynchronous: bool) -> None:
respond: Final = MagicMock(return_value=httpx.Response(200, json=_opensearch_hits("text", "embedding")))
response: Final = await _search(
asynchronous,
respond,
RecordingEmbeddingExecutor(),
{"vector_store_id": "east-docs", "opensearch_index_name": "docs/v2"},
)
assert response["data"] == EXPECTED_RESULTS
assert str(_sent(respond)[0].url) == f"{SERVERLESS}/docs%2Fv2/_search"
@pytest.mark.parametrize(
"unsupported",
[
{"filters": {"type": "eq", "key": "category", "value": "hr"}},
{"ranking_options": {"score_threshold": 0.5}},
{"rewrite_query": True},
],
)
@pytest.mark.asyncio
async def test_unsupported_search_params_are_dropped_under_drop_params(unsupported: Mapping[str, object]) -> None:
respond: Final = MagicMock(return_value=httpx.Response(200, json=_opensearch_hits("text", "embedding")))
response: Final = await _search(
True, respond, RecordingEmbeddingExecutor(), {**unsupported, "drop_params": True, "max_num_results": 3}
)
assert response["data"] == EXPECTED_RESULTS
assert json.loads(_sent(respond)[0].content) == {
"size": 3,
"query": {"knn": {"embedding": {"vector": QUERY_VECTOR, "k": 3}}},
"_source": {"excludes": ["embedding"]},
}
@pytest.mark.asyncio
async def test_no_op_values_of_unsupported_params_are_accepted() -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_opensearch_hits("text", "embedding"))
response: Final = await _search(
True, respond, RecordingEmbeddingExecutor(), {"filters": {}, "ranking_options": {}, "rewrite_query": False}
)
assert response["data"] == EXPECTED_RESULTS
def test_create_is_rejected_because_the_store_is_search_only() -> None:
with pytest.raises(litellm.BadRequestError, match="search-only"):
litellm.vector_stores.create(custom_llm_provider="opensearch")
async def _store_operation(
asynchronous: bool, operation: str, respond: Callable[[httpx.Request], httpx.Response]
) -> object:
request_kwargs: Final = {**BASE_PARAMS, "vector_store_id": "litellm-qa-docs", "custom_llm_provider": "opensearch"}
if asynchronous:
return await getattr(vector_stores, f"a{operation}")(
**request_kwargs, client=AsyncHTTPHandler(transport=httpx.MockTransport(respond))
)
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
return getattr(vector_stores, operation)(**request_kwargs, client=HTTPHandler(client=transport))
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("operation", ["retrieve", "update", "delete"])
@pytest.mark.asyncio
async def test_store_operations_besides_search_send_nothing_to_the_index(asynchronous: bool, operation: str) -> None:
respond: Final = MagicMock(return_value=httpx.Response(200, json={"acknowledged": True}))
with pytest.raises(litellm.BadRequestError, match="search-only") as error:
await _store_operation(asynchronous, operation, respond)
assert error.value.status_code == 400
respond.assert_not_called()
async def _list_stores(asynchronous: bool, respond: Callable[[httpx.Request], httpx.Response]) -> object:
request_kwargs: Final = {**BASE_PARAMS, "custom_llm_provider": "opensearch"}
if asynchronous:
return await vector_stores.alist(
**request_kwargs, client=AsyncHTTPHandler(transport=httpx.MockTransport(respond))
)
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
return vector_stores.list(**request_kwargs, client=HTTPHandler(client=transport))
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.asyncio
async def test_listing_stores_sends_nothing_to_the_endpoint(asynchronous: bool) -> None:
respond: Final = MagicMock(return_value=httpx.Response(200, json={"cluster_name": "docs", "tagline": "OpenSearch"}))
with pytest.raises(litellm.BadRequestError, match="search-only") as error:
await _list_stores(asynchronous, respond)
assert error.value.status_code == 400
respond.assert_not_called()
@pytest.mark.parametrize(
"response,status",
[
(httpx.Response(403, json={"status": 403, "error": {"type": "Forbidden"}}), 403),
(httpx.Response(200, json={"took": 1}), 502),
(httpx.Response(200, text="<html>gateway</html>"), 502),
],
)
def test_response_without_search_hits_is_an_error_not_an_empty_page(response: httpx.Response, status: int) -> None:
logging: Final = Logging(
model="",
messages=[],
stream=False,
call_type="vector_store_search",
start_time=datetime(2026, 10, 20, tzinfo=timezone.utc),
litellm_call_id="opensearch-test",
function_id="opensearch-test",
)
with pytest.raises(BaseLLMException) as error:
OpenSearchVectorStoreConfig().transform_search_vector_store_response(response, logging)
assert error.value.status_code == status

View file

@ -1,4 +1,6 @@
import warnings
from typing import Final
import pytest
from pydantic import ValidationError
@ -908,6 +910,22 @@ class TestCacheControl:
assert "cache_control" not in content[0]
assert content[1].get("cache_control") == {"type": "ephemeral"}
def test_pdf_file_part_reaches_sap_as_an_image_url_data_url(self) -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQK"
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "Summarize this."},
{"type": "file", "file": {"filename": "report.pdf", "file_data": pdf_data_url}},
],
}
]
body: Final = self._transform("anthropic--claude-3-5-sonnet", messages)
content: Final = self._template(body)[0]["content"]
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"] == pdf_data_url
def test_message_level_cache_control_folded_onto_trailing_image_block(self):
messages = [
{

View file

@ -18,6 +18,7 @@ import pytest
import litellm
from litellm import completion, acompletion
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.anthropic.messages.adapters.transformation import LiteLLMAnthropicMessagesAdapter
from litellm.llms.snowflake.chat.transformation import SnowflakeConfig, SnowflakeStreamingHandler
from litellm.types.utils import ModelResponse
@ -479,6 +480,35 @@ class TestSnowflakeCortexClaudeFixes:
}
]
def test_messages_api_pdf_reaches_cortex_claude_as_a_document_block(self) -> None:
translated: Final = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": "ZmFrZQ=="},
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": "What is the codeword?"},
],
}
],
model="snowflake/claude-sonnet-4-6",
)
body: Final = self._transform(translated)
assert body["messages"][0]["content"] == [
{
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": "ZmFrZQ=="},
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": "What is the codeword?"},
]
def test_multipart_tool_result_preserves_text_and_converts_image(self):
body = self._transform(
[

View file

@ -0,0 +1 @@
"""Soniox realtime tests."""

View file

@ -0,0 +1,807 @@
import asyncio
import base64
import json
from typing import Final
from unittest.mock import MagicMock
import pytest
from litellm.llms.soniox.common_utils import SonioxException
from litellm.llms.soniox.realtime.transformation import (
DEFAULT_SONIOX_REALTIME_URL,
SESSION_STARTED_FRAME,
SonioxEventTransformer,
SonioxRealtimeBackend,
SonioxProtocolError,
SonioxRealtimeConfig,
SonioxRealtimeOptions,
)
from litellm.types.llms.openai import OpenAIRealtimeEvents
from litellm.types.realtime import RealtimeResponseTransformInput
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
MODEL: Final = "stt-rt-v5"
EMPTY_TRANSFORM_INPUT: Final[RealtimeResponseTransformInput] = {
"session_configuration_request": None,
"current_output_item_id": None,
"current_response_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_conversation_id": None,
"current_delta_type": None,
}
PCM: Final = b"\x01\x02" * 160
def _event(event_type: str, **fields: object) -> str:
return json.dumps({"type": event_type, **fields})
def _session_update(audio_input: dict[str, object]) -> str:
return _event("session.update", session={"type": "transcription", "audio": {"input": audio_input}})
def _append() -> str:
return _event("input_audio_buffer.append", audio=base64.b64encode(PCM).decode())
def _start_request(frames: tuple[str | bytes, ...]) -> dict[str, object]:
assert isinstance(frames[0], str)
return json.loads(frames[0])
def _response(tokens: list[dict[str, object]], total_audio_proc_ms: int | None = None) -> str:
return json.dumps({"tokens": tokens, "total_audio_proc_ms": total_audio_proc_ms})
def _token(text: str, is_final: bool = True, **fields: object) -> dict[str, object]:
return {"text": text, "is_final": is_final, **fields}
def _types(events: tuple[OpenAIRealtimeEvents, ...]) -> list[object]:
return [event["type"] for event in events]
def test_session_update_starts_the_stream_with_client_audio_and_deployment_options():
config = SonioxRealtimeConfig(
options=SonioxRealtimeOptions(language_hints=("en", "fr"), enable_speaker_diarization=True)
)
frames = config.transform_realtime_request(
_session_update(
{
"format": {"type": "audio/pcm", "rate": 16_000},
"transcription": {"model": "client-chosen-model", "language": "fr"},
"turn_detection": None,
}
),
MODEL,
)
assert _start_request(frames) == {
"model": MODEL,
"audio_format": "pcm_s16le",
"sample_rate": 16_000,
"num_channels": 1,
"enable_endpoint_detection": False,
"language_hints": ["fr", "en"],
"enable_speaker_diarization": True,
}
def test_an_empty_transcription_language_keeps_the_deployment_language_hints():
config = SonioxRealtimeConfig(options=SonioxRealtimeOptions(language_hints=("fa",)))
frames = config.transform_realtime_request(_session_update({"transcription": {"language": ""}}), MODEL)
assert _start_request(frames)["language_hints"] == ["fa"]
def test_a_client_language_cannot_widen_strict_deployment_language_hints():
config = SonioxRealtimeConfig(options=SonioxRealtimeOptions(language_hints=("fa",), language_hints_strict=True))
frames = config.transform_realtime_request(_session_update({"transcription": {"language": "en"}}), MODEL)
assert _start_request(frames)["language_hints"] == ["fa"]
assert config.transform_realtime_request(_session_update({"transcription": {"language": "en"}}), MODEL) == ()
def test_a_soniox_native_config_frame_is_rejected_without_starting_the_stream():
config = SonioxRealtimeConfig()
with pytest.raises(SonioxProtocolError):
config.transform_realtime_request(json.dumps({"model": "stt-rt-v5", "audio_format": "auto"}), MODEL)
assert _start_request(config.transform_realtime_request(_append(), MODEL))["sample_rate"] == 24_000
def test_audio_before_session_update_starts_a_default_stream_once_and_rejects_a_later_change():
config = SonioxRealtimeConfig()
first = config.transform_realtime_request(_append(), MODEL)
second = config.transform_realtime_request(_append(), MODEL)
with pytest.raises(SonioxProtocolError, match="once the stream has started"):
config.transform_realtime_request(_session_update({"turn_detection": None}), MODEL)
assert _start_request(first) == {
"model": MODEL,
"audio_format": "pcm_s16le",
"sample_rate": 24_000,
"num_channels": 1,
"enable_endpoint_detection": True,
}
assert first[1:] == (PCM,)
assert second == (PCM,)
assert config.transform_realtime_request(_append(), MODEL) == (PCM,)
@pytest.mark.parametrize(
("event_type", "control_frame"),
[("input_audio_buffer.commit", '{"type":"finalize"}'), ("input_audio_buffer.end", "")],
)
def test_buffer_control_events_map_to_soniox_control_frames(event_type: str, control_frame: str):
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
assert config.transform_realtime_request(_event(event_type), MODEL) == (control_frame,)
@pytest.mark.parametrize("event_type", ["response.create", "input_audio_buffer.clear"])
def test_events_with_nothing_to_send_upstream_are_dropped_without_starting_the_stream(event_type: str):
config = SonioxRealtimeConfig()
assert config.transform_realtime_request(_event(event_type), MODEL) == ()
assert _start_request(config.transform_realtime_request(_append(), MODEL))["model"] == MODEL
def _events(config: SonioxRealtimeConfig, tokens: list[dict[str, object]]) -> tuple[OpenAIRealtimeEvents, ...]:
return tuple(
config.transform_realtime_response(_response(tokens), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT)["response"]
)
def test_clear_discards_the_open_turn_and_everything_soniox_finalizes_for_it():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
_events(config, [_token("Hel")])
assert config.transform_realtime_request(_event("input_audio_buffer.clear"), MODEL) == ('{"type":"finalize"}',)
cleared = _events(config, [_token("lo"), _token("<end>"), _token("<fin>")])
config.transform_realtime_request(_event("input_audio_buffer.commit"), MODEL)
kept = _events(config, [_token("Bye"), _token("<fin>")])
assert cleared == ()
assert kept[-1]["transcript"] == "Bye"
def test_clear_after_a_commit_keeps_the_committed_turn_and_drops_only_audio_sent_after_it():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
_events(config, [_token("One")])
config.transform_realtime_request(_event("input_audio_buffer.commit"), MODEL)
config.transform_realtime_request(_event("input_audio_buffer.clear"), MODEL)
committed = _events(config, [_token("<fin>")])
cleared = _events(config, [_token(" two"), _token("<fin>")])
later = _events(config, [_token("Three"), _token("<end>")])
assert committed[-1]["transcript"] == "One"
assert cleared == ()
assert later[-1]["transcript"] == "Three"
@pytest.mark.parametrize(
"audio_input",
[
{"format": {"type": "audio/pcmu"}},
{"format": {"type": "audio/pcm", "rate": 24_000, "channels": 2}},
{"format": {"type": "audio/pcm", "rate": 0}},
],
)
def test_unsupported_audio_formats_are_rejected_without_starting_the_stream(audio_input: dict[str, object]):
config = SonioxRealtimeConfig()
with pytest.raises(SonioxProtocolError):
config.transform_realtime_request(_session_update(audio_input), MODEL)
assert _start_request(config.transform_realtime_request(_append(), MODEL))["sample_rate"] == 24_000
@pytest.mark.parametrize(
("audio_input", "sample_rate"),
[({"format": {"type": "audio/pcm"}}, 24_000), ({"input_audio_format": "pcm16"}, 24_000)],
)
def test_pcm16_without_a_rate_streams_at_the_openai_default_rate(audio_input: dict[str, object], sample_rate: int):
config = SonioxRealtimeConfig()
frames = config.transform_realtime_request(_session_update(audio_input), MODEL)
assert _start_request(frames)["sample_rate"] == sample_rate
def test_validate_environment_sends_the_key_as_a_bearer_header(monkeypatch):
monkeypatch.setenv("SONIOX_API_KEY", "env-key")
config = SonioxRealtimeConfig()
assert config.validate_environment({"x": "y"}, MODEL, "deployment-key") == {
"x": "y",
"Authorization": "Bearer deployment-key",
}
assert config.validate_environment({}, MODEL) == {"Authorization": "Bearer env-key"}
def test_validate_environment_rejects_a_missing_key(monkeypatch):
monkeypatch.delenv("SONIOX_API_KEY", raising=False)
with pytest.raises(ValueError, match="SONIOX_API_KEY"):
SonioxRealtimeConfig().validate_environment({}, MODEL)
def test_get_complete_url_defaults_to_soniox_and_opens_a_websocket_for_http_bases():
config = SonioxRealtimeConfig()
assert config.get_complete_url(None, MODEL) == DEFAULT_SONIOX_REALTIME_URL
assert config.get_complete_url("wss://proxy.example/ws", MODEL) == "wss://proxy.example/ws"
assert config.get_complete_url("https://proxy.example/ws?region=eu", MODEL) == "wss://proxy.example/ws?region=eu"
assert config.get_complete_url("http://localhost:8080/ws", MODEL) == "ws://localhost:8080/ws"
with pytest.raises(ValueError, match="wss://"):
config.get_complete_url("ftp://proxy.example/ws", MODEL)
def test_endpoint_detection_emits_one_openai_turn_billed_by_processed_audio():
transformer = SonioxEventTransformer(drops_originals=False)
provisional = transformer.transform(_response([_token("Hel", is_final=False)], total_audio_proc_ms=500))
finals = transformer.transform(
_response([_token("Hello"), _token(" world"), _token(" next", is_final=False)], total_audio_proc_ms=1500)
)
endpoint = transformer.transform(_response([_token("<end>")], total_audio_proc_ms=2000))
assert _types(provisional) == ["input_audio_buffer.speech_started"]
item_id = provisional[0]["item_id"]
assert [(event["type"], event.get("delta")) for event in finals] == [
("conversation.item.input_audio_transcription.delta", "Hello"),
("conversation.item.input_audio_transcription.delta", " world"),
]
assert _types(endpoint) == [
"input_audio_buffer.speech_stopped",
"conversation.item.input_audio_transcription.completed",
]
assert {event["item_id"] for event in (*finals, *endpoint)} == {item_id}
assert all("speaker" not in event and "language" not in event for event in finals)
assert endpoint[1]["transcript"] == "Hello world"
assert "segments" not in endpoint[1]
assert endpoint[1]["usage"] == {"type": "duration", "seconds": 2.0}
assert transformer.take_unbilled_usage() is None
def test_manual_finalization_completes_the_turn_without_speech_stopped_and_starts_a_new_item():
transformer = SonioxEventTransformer(drops_originals=False)
first = transformer.transform(_response([_token("One"), _token("<fin>")]))
second = transformer.transform(_response([_token("Two"), _token("<fin>")]))
assert _types(first) == [
"input_audio_buffer.speech_started",
"conversation.item.input_audio_transcription.delta",
"conversation.item.input_audio_transcription.completed",
]
assert first[-1]["transcript"] == "One"
assert "usage" not in first[-1]
assert second[-1]["transcript"] == "Two"
assert second[0]["item_id"] != first[0]["item_id"]
def test_a_commit_without_speech_answers_with_an_empty_turn_billed_for_the_silence():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
config.transform_realtime_request(_event("input_audio_buffer.commit"), MODEL)
events = tuple(
config.transform_realtime_response(
_response([_token("<fin>")], total_audio_proc_ms=2000), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT
)["response"]
)
assert _types(events) == ["conversation.item.input_audio_transcription.completed"]
assert events[0]["transcript"] == ""
assert events[0]["usage"] == {"type": "duration", "seconds": 2.0}
assert _events(config, [_token("Next"), _token("<end>")])[0]["item_id"] != events[0]["item_id"]
def test_a_commit_soniox_answers_after_its_endpoint_already_ended_the_turn_adds_no_second_turn():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
_events(config, [_token("Hello")])
config.transform_realtime_request(_event("input_audio_buffer.commit"), MODEL)
events = _events(config, [_token("<end>"), _token("<fin>")])
assert [event["transcript"] for event in events if "transcript" in event] == ["Hello"]
def test_a_server_vad_turn_answers_only_the_oldest_pending_commit():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
_events(config, [_token("Hello")])
config.transform_realtime_request(_event("input_audio_buffer.commit"), MODEL)
config.transform_realtime_request(_append(), MODEL)
config.transform_realtime_request(_event("input_audio_buffer.commit"), MODEL)
events = _events(config, [_token("<end>"), _token("<fin>"), _token("<fin>")])
assert [event["transcript"] for event in events if "transcript" in event] == ["Hello", ""]
def test_a_clear_without_speech_still_answers_nothing():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
config.transform_realtime_request(_event("input_audio_buffer.clear"), MODEL)
assert _events(config, [_token("<fin>")]) == ()
def test_translation_sessions_transcribe_only_the_translated_tokens():
transformer = SonioxEventTransformer(drops_originals=True)
events = transformer.transform(
_response(
[
_token("Hola", translation_status="original"),
_token("Hello", translation_status="translation"),
_token("<end>"),
]
)
)
assert [event.get("delta") for event in events if "delta" in event] == ["Hello"]
assert events[-1]["transcript"] == "Hello"
def test_translation_sessions_keep_speech_soniox_leaves_untranslated():
transformer = SonioxEventTransformer(drops_originals=True)
events = transformer.transform(
_response(
[_token("Hola", translation_status="none"), _token(" amigos", translation_status="none"), _token("<end>")]
)
)
assert events[-1]["transcript"] == "Hola amigos"
def test_diarized_tokens_label_each_delta_and_split_the_completed_turn_into_speaker_segments():
transformer = SonioxEventTransformer(drops_originals=False)
events = transformer.transform(
_response(
[
_token("Hi", speaker="1", language="en"),
_token(" doctor", speaker="1", language="en"),
_token(" Salam", speaker="2", language="fa"),
_token(" Hi", speaker="1", language="en"),
_token("<end>"),
]
)
)
assert [(event["delta"], event["speaker"], event["language"]) for event in events if "delta" in event] == [
("Hi", "1", "en"),
(" doctor", "1", "en"),
(" Salam", "2", "fa"),
(" Hi", "1", "en"),
]
assert events[-1]["transcript"] == "Hi doctor Salam Hi"
assert events[-1]["segments"] == (
{"speaker": "1", "language": "en", "text": "Hi doctor"},
{"speaker": "2", "language": "fa", "text": "Salam"},
{"speaker": "1", "language": "en", "text": "Hi"},
)
def test_translation_sessions_announce_speech_on_the_first_spoken_token_before_any_translation():
transformer = SonioxEventTransformer(drops_originals=True)
events = transformer.transform(_response([_token("Hol", is_final=False, translation_status="original")]))
assert _types(events) == ["input_audio_buffer.speech_started"]
def test_provider_errors_surface_as_openai_error_events():
transformer = SonioxEventTransformer(drops_originals=False)
events = transformer.transform(json.dumps({"error_code": 401, "error_message": "Invalid API key."}))
assert events == (
{"type": "error", "error": {"type": "server_error", "message": "Soniox realtime error 401: Invalid API key."}},
)
def test_malformed_provider_frames_are_rejected():
with pytest.raises(SonioxProtocolError):
SonioxEventTransformer(drops_originals=False).transform('{"tokens": "nope"}')
def test_audio_processed_after_the_last_turn_is_billed_on_session_close():
config = SonioxRealtimeConfig()
config.transform_realtime_response(
_response([_token("Hi"), _token("<end>")], total_audio_proc_ms=1000), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT
)
config.transform_realtime_response(
_response([], total_audio_proc_ms=3500), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT
)
assert config.unbilled_usage_on_session_close(MODEL) == {"type": "duration", "seconds": 2.5}
assert config.unbilled_usage_on_session_close(MODEL) is None
def test_translation_option_switches_the_config_to_translated_transcripts():
config = SonioxRealtimeConfig(
options=SonioxRealtimeOptions(translation={"type": "one_way", "target_language": "en"})
)
result = config.transform_realtime_response(
_response([_token("Hola", translation_status="original"), _token("Hello", translation_status="translation")]),
MODEL,
MagicMock(),
EMPTY_TRANSFORM_INPUT,
)
assert [event.get("delta") for event in result["response"] if "delta" in event] == ["Hello"]
def test_stream_time_since_the_start_request_is_billed_on_close_because_soniox_charges_the_whole_stream():
now = [100.0]
config = SonioxRealtimeConfig(clock=lambda: now[0])
config.wrap_backend(_RecordingBackend())
now[0] = 110.0
config.transform_realtime_request(_append(), MODEL)
config.transform_realtime_response(
_response([_token("Hi"), _token("<end>")], total_audio_proc_ms=5000), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT
)
now[0] = 130.5
assert config.unbilled_usage_on_session_close(MODEL) == {"type": "duration", "seconds": 15.5}
assert config.unbilled_usage_on_session_close(MODEL) is None
def _refusal_after(processed_ms: int, error_code: int = 401) -> SonioxRealtimeConfig:
now = [100.0]
config = SonioxRealtimeConfig(clock=lambda: now[0])
config.transform_realtime_request(_append(), MODEL)
if processed_ms:
config.transform_realtime_response(
_response([_token("Hi")], total_audio_proc_ms=processed_ms), MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT
)
config.transform_realtime_response(
json.dumps({"error_code": error_code, "error_message": "Soniox error.", "total_audio_proc_ms": processed_ms}),
MODEL,
MagicMock(),
EMPTY_TRANSFORM_INPUT,
)
now[0] = 103.0
return config
@pytest.mark.parametrize("error_code", [400, 401, 402, 403, 408, 429, 500, 503])
def test_an_error_before_any_audio_is_processed_is_a_refused_session_that_bills_nothing(error_code: int):
config = _refusal_after(processed_ms=0, error_code=error_code)
refusal = config.session_refusal()
assert isinstance(refusal, SonioxException)
assert refusal.status_code == error_code
assert config.unbilled_usage_on_session_close(MODEL) is None
def test_an_error_after_audio_was_processed_keeps_the_session_a_billed_success():
config = _refusal_after(processed_ms=1500)
assert config.session_refusal() is None
assert config.unbilled_usage_on_session_close(MODEL) == {"type": "duration", "seconds": 3.0}
def test_a_connection_that_never_started_a_stream_bills_nothing():
now = [100.0]
config = SonioxRealtimeConfig(clock=lambda: now[0])
config.wrap_backend(_RecordingBackend())
now[0] = 160.0
assert config.unbilled_usage_on_session_close(MODEL) is None
def test_soniox_opts_out_of_deferred_setup_so_client_frames_reach_it_in_order():
assert SonioxRealtimeConfig().requires_session_configuration()
def test_a_session_update_matching_the_started_stream_is_a_no_op():
config = SonioxRealtimeConfig()
config.transform_realtime_request(_append(), MODEL)
assert (
config.transform_realtime_request(_session_update({"format": {"type": "audio/pcm", "rate": 24_000}}), MODEL)
== ()
)
def _stream_started_at_16khz_in_french_without_vad() -> SonioxRealtimeConfig:
config = SonioxRealtimeConfig()
config.transform_realtime_request(
_session_update(
{
"format": {"type": "audio/pcm", "rate": 16_000},
"transcription": {"language": "fr"},
"turn_detection": None,
}
),
MODEL,
)
return config
@pytest.mark.parametrize(
"follow_up",
[
{"transcription": {"language": "fr"}},
{"format": {"type": "audio/pcm", "rate": 16_000}},
{"format": {"type": "audio/pcm"}},
{"turn_detection": None},
],
)
def test_a_partial_follow_up_update_that_restates_the_stream_is_a_no_op(follow_up: dict[str, object]):
config = _stream_started_at_16khz_in_french_without_vad()
assert config.transform_realtime_request(_session_update(follow_up), MODEL) == ()
def test_a_follow_up_update_with_an_unsupported_encoding_is_still_rejected():
config = _stream_started_at_16khz_in_french_without_vad()
with pytest.raises(SonioxProtocolError, match="requires pcm16"):
config.transform_realtime_request(_session_update({"format": {"type": "audio/pcmu"}}), MODEL)
@pytest.mark.parametrize(
"follow_up",
[
{"transcription": {"language": "de"}},
{"format": {"type": "audio/pcm", "rate": 24_000}},
{"turn_detection": {"type": "server_vad"}},
],
)
def test_a_partial_follow_up_update_that_changes_a_sent_setting_is_rejected(follow_up: dict[str, object]):
config = _stream_started_at_16khz_in_french_without_vad()
with pytest.raises(SonioxProtocolError, match="once the stream has started"):
config.transform_realtime_request(_session_update(follow_up), MODEL)
def test_session_created_event_advertises_the_deployment_model_for_cost_tracking():
config = SonioxRealtimeConfig(options=SonioxRealtimeOptions(language_hints=("de",)))
event = config.transform_session_created_event(MODEL, "session_1")
assert event["session"]["id"] == "session_1"
assert event["session"]["audio"]["input"]["transcription"] == {"model": MODEL, "language": "de"}
def test_backend_session_start_is_announced_with_the_negotiated_session():
config = SonioxRealtimeConfig()
config.transform_session_created_event(MODEL, "session_1")
config.transform_realtime_request(
_session_update(
{
"format": {"type": "audio/pcm", "rate": 16_000},
"transcription": {"language": "it"},
"turn_detection": None,
}
),
MODEL,
)
result = config.transform_realtime_response(SESSION_STARTED_FRAME, MODEL, MagicMock(), EMPTY_TRANSFORM_INPUT)
assert _types(tuple(result["response"])) == ["session.created"]
assert result["response"][0]["session"]["id"] == "session_1"
assert result["response"][0]["session"]["audio"]["input"] == {
"format": {"type": "audio/pcm", "rate": 16_000},
"transcription": {"model": MODEL, "language": "it"},
"turn_detection": None,
}
def test_provider_config_manager_passes_only_soniox_options_from_deployment_params():
config = ProviderConfigManager.get_provider_realtime_config(
MODEL,
LlmProviders.SONIOX,
litellm_params={
"api_key": "secret",
"model": "soniox/stt-rt-v5",
"audio_format": "mulaw",
"language_hints": ["pl"],
"context": {"terms": ["LiteLLM"]},
"max_endpoint_delay_ms": 800,
},
)
assert isinstance(config, SonioxRealtimeConfig)
assert _start_request(config.transform_realtime_request(_append(), MODEL)) == {
"model": MODEL,
"audio_format": "pcm_s16le",
"sample_rate": 24_000,
"num_channels": 1,
"enable_endpoint_detection": True,
"language_hints": ["pl"],
"context": {"terms": ["LiteLLM"]},
"max_endpoint_delay_ms": 800,
}
SEMANTIC_VAD: Final = {"turn_detection": {"type": "semantic_vad", "eagerness": "high"}}
def _soniox_config(drop_params: bool) -> SonioxRealtimeConfig:
config = ProviderConfigManager.get_provider_realtime_config(MODEL, LlmProviders.SONIOX, drop_params=drop_params)
assert isinstance(config, SonioxRealtimeConfig)
return config
def test_unsupported_turn_detection_is_rejected_without_drop_params():
config = _soniox_config(drop_params=False)
with pytest.raises(SonioxProtocolError, match="Set drop_params to use server_vad instead"):
config.transform_realtime_request(_session_update(SEMANTIC_VAD), MODEL)
def test_unsupported_turn_detection_falls_back_to_endpoint_detection_with_drop_params():
config = _soniox_config(drop_params=True)
frames = config.transform_realtime_request(_session_update(SEMANTIC_VAD), MODEL)
assert _start_request(frames)["enable_endpoint_detection"] is True
assert config.transform_realtime_request(_session_update(SEMANTIC_VAD), MODEL) == ()
def test_a_follow_up_with_unsupported_turn_detection_is_rejected_without_drop_params():
config = _soniox_config(drop_params=False)
config.transform_realtime_request(_append(), MODEL)
with pytest.raises(SonioxProtocolError, match="Set drop_params to use server_vad instead"):
config.transform_realtime_request(_session_update(SEMANTIC_VAD), MODEL)
class _RecordingBackend:
def __init__(self) -> None:
self.sent: Final[list[str | bytes]] = []
self.upstream: Final[asyncio.Queue[str | Exception]] = asyncio.Queue()
self.entered = False
self.exited = False
self.closed = False
async def __aenter__(self) -> "_RecordingBackend":
self.entered = True
return self
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
self.exited = True
async def send(self, message: str | bytes) -> None:
self.sent.append(message)
async def recv(self, decode: bool | None = None) -> str | bytes:
frame = await self.upstream.get()
if isinstance(frame, Exception):
raise frame
return frame
async def close(self) -> None:
self.closed = True
class _BrokenBackend(_RecordingBackend):
async def send(self, message: str | bytes) -> None:
raise ConnectionError("upstream gone")
@pytest.mark.asyncio
async def test_keepalive_is_sent_only_while_the_client_is_idle_and_stops_after_end_of_stream():
now = [0.0]
ticks: asyncio.Queue[float] = asyncio.Queue()
async def sleep(_: float) -> None:
now[0] = await ticks.get()
async def tick(at: float) -> None:
await ticks.put(at)
for _ in range(5):
await asyncio.sleep(0)
inner = _RecordingBackend()
backend = SonioxRealtimeBackend(inner, keepalive_interval=5.0, clock=lambda: now[0], sleep=sleep)
async with backend:
await tick(5.0)
assert inner.sent == ['{"type":"keepalive"}']
now[0] = 7.0
await backend.send(PCM)
await tick(10.0)
assert inner.sent == ['{"type":"keepalive"}', PCM]
await backend.send("")
await tick(30.0)
assert inner.sent == ['{"type":"keepalive"}', PCM, ""]
assert inner.entered and inner.exited
@pytest.mark.asyncio
async def test_backend_announces_the_session_once_the_start_request_is_sent_while_relaying_upstream_frames():
inner = _RecordingBackend()
backend = SonioxRealtimeBackend(inner)
async with backend:
pending = asyncio.create_task(backend.recv())
await backend.send('{"type":"keepalive"}')
await asyncio.sleep(0)
assert not pending.done()
await backend.send('{"model":"stt-rt-v5"}')
assert await pending == SESSION_STARTED_FRAME
await backend.send('{"model":"ignored-second-config"}')
await inner.upstream.put('{"tokens":[]}')
assert await backend.recv() == '{"tokens":[]}'
@pytest.mark.asyncio
async def test_backend_relays_upstream_frames_that_arrive_before_the_session_starts():
inner = _RecordingBackend()
async with SonioxRealtimeBackend(inner) as backend:
await inner.upstream.put('{"error_code":401,"error_message":"Invalid API key."}')
assert await backend.recv() == '{"error_code":401,"error_message":"Invalid API key."}'
@pytest.mark.asyncio
async def test_a_cancelled_read_loses_no_frames_and_upstream_errors_reach_the_reader():
inner = _RecordingBackend()
async with SonioxRealtimeBackend(inner) as backend:
cancelled = asyncio.create_task(backend.recv())
await asyncio.sleep(0)
cancelled.cancel()
await asyncio.gather(cancelled, return_exceptions=True)
await inner.upstream.put('{"tokens":[]}')
await inner.upstream.put(ConnectionError("upstream closed"))
assert await backend.recv() == '{"tokens":[]}'
with pytest.raises(ConnectionError, match="upstream closed"):
await backend.recv()
@pytest.mark.asyncio
async def test_closing_the_backend_cancels_pending_reads_and_closes_upstream():
inner = _RecordingBackend()
backend = SonioxRealtimeBackend(inner)
await backend.__aenter__()
pending = asyncio.create_task(backend.recv())
await backend.send('{"model":"stt-rt-v5"}')
assert await pending == SESSION_STARTED_FRAME
await backend.close()
await inner.upstream.put('{"tokens":[]}')
await asyncio.sleep(0)
assert inner.closed
assert inner.upstream.qsize() == 1
@pytest.mark.asyncio
async def test_keepalive_stops_after_the_upstream_rejects_a_send():
sleeps: list[float] = []
async def sleep(seconds: float) -> None:
sleeps.append(seconds)
await asyncio.sleep(0)
async with SonioxRealtimeBackend(_BrokenBackend(), keepalive_interval=0.0, sleep=sleep):
for _ in range(5):
await asyncio.sleep(0)
assert sleeps == [0.0]

View file

@ -143,6 +143,7 @@ API_BASE_IS_THE_FULL_URL: Final[Mapping[str, str]] = MappingProxyType(
"duckduckgo.search.transformation.DuckDuckGoSearchConfig",
"google_pse.search.transformation.GooglePSESearchConfig",
"searchapi.search.transformation.SearchAPIConfig",
"soniox.realtime.transformation.SonioxRealtimeConfig",
"tinyfish.search.transformation.TinyfishSearchConfig",
"vertex_ai.text_to_speech.transformation.VertexAITextToSpeechConfig",
"vertex_ai.vertex_ai_partner_models.anthropic.messages.transformation."

View file

@ -13,6 +13,7 @@ These tests verify that:
import json
import os
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -422,3 +423,52 @@ async def test_vertex_ai_gemma_vision_passthrough():
assert isinstance(content, list), f"Expected list content, got: {content}"
image_parts = [p for p in content if p.get("type") == "image_url"]
assert image_parts, f"No image_url part in forwarded message content: {content}"
@pytest.mark.asyncio
async def test_vertex_ai_gemma_pdf_file_part_is_sent_as_image_url() -> None:
pdf_data_url: Final = "data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PD4+CmVuZG9iagp0cmFpbGVyCjw8Pj4KJSVFT0YK"
messages: Final = [
{
"role": "user",
"content": [
{"type": "text", "text": "What does the PDF say?"},
{"type": "file", "file": {"file_data": pdf_data_url, "filename": "report.pdf"}},
],
}
]
mock_response: Final = MagicMock()
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = _MOCK_RESPONSE_JSON
mock_vertexai: Final = MagicMock()
mock_vertexai.preview = MagicMock()
with (
patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler") as mock_http_handler,
patch(
"litellm.llms.vertex_ai.vertex_ai_partner_models.main.VertexAIPartnerModels._ensure_access_token",
return_value=("fake-token", "test-project"),
),
patch.dict(
"sys.modules",
{"vertexai": mock_vertexai, "vertexai.preview": mock_vertexai.preview},
),
patch.dict(litellm.model_cost, _GEMMA_MODEL_COST_ENTRY, clear=False),
):
mock_http_handler.return_value.post = AsyncMock(return_value=mock_response)
await litellm.acompletion(
model="vertex_ai/google/gemma-4-26b-a4b-it-maas",
messages=messages,
vertex_ai_project="test-project",
)
body: Final = json.loads(mock_http_handler.return_value.post.call_args.kwargs["data"])
user_msg: Final = next(m for m in body["messages"] if m["role"] == "user")
assert user_msg["content"] == [
{"type": "text", "text": "What does the PDF say?"},
{"type": "image_url", "image_url": {"url": pdf_data_url}},
]

View file

@ -49,6 +49,19 @@ def test_get_remaining_tokens_and_requests_from_request_data():
}
@pytest.mark.parametrize("client_model_group", [7, ["embed-only"], {"name": "embed-only"}])
def test_remaining_limit_headers_ignore_a_model_group_that_is_not_a_string(client_model_group: object):
casedata: Final = {
"metadata": {
"model_group": client_model_group,
f"litellm-key-remaining-requests-{client_model_group}": 100,
f"litellm-key-remaining-tokens-{client_model_group}": 200,
}
}
assert get_remaining_tokens_and_requests_from_request_data(casedata) == {}
@patch(
"litellm.proxy.common_utils.callback_utils.CustomLogger.get_callback_env_vars",
return_value=["API_KEY", "MISSING_VAR"],

View file

@ -1,6 +1,7 @@
import asyncio
import base64
from collections.abc import Mapping, Sequence
from typing import Final
from unittest.mock import AsyncMock, patch
import pytest
@ -8,6 +9,7 @@ from fastapi.exceptions import HTTPException
from httpx import ReadTimeout, Request, Response
import litellm
from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.prompt_security.prompt_security import (
PromptSecurityGuardrail,
@ -1247,3 +1249,54 @@ async def test_check_tool_results_enabled(monkeypatch: pytest.MonkeyPatch):
assert "indirect_prompt_injection" in str(excinfo.value.detail)
@pytest.mark.asyncio
async def test_messages_api_pdf_reaches_file_sanitization_and_is_blocked() -> None:
sanitize_url: Final = "https://test.prompt.security/api/sanitizeFile"
upload: Final = AsyncMock(
return_value=Response(200, json={"jobId": "pdf-job"}, request=Request("POST", sanitize_url))
)
poll: Final = AsyncMock(
return_value=Response(
200,
json={"status": "done", "content": "", "metadata": {"action": "block", "violations": ["Malicious File"]}},
request=Request("GET", sanitize_url),
)
)
guardrail: Final = PromptSecurityGuardrail(
api_key="test-key",
api_base="https://test.prompt.security",
guardrail_name="test-guard",
event_hook="pre_call",
default_on=True,
)
guardrail.poll_interval = 0
pdf: Final = b"%PDF-1.4 pelican"
request: Final = {
"model": "gpt-5.6",
"max_tokens": 64,
"messages": [
{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": base64.b64encode(pdf).decode(),
},
},
{"type": "text", "text": "Summarize this PDF"},
],
}
],
}
with patch.object(guardrail.async_handler, "post", upload), patch.object(guardrail.async_handler, "get", poll):
with pytest.raises(HTTPException) as blocked:
await AnthropicMessagesHandler().process_input_messages(request, guardrail)
assert blocked.value.status_code == 400
assert "Malicious File" in str(blocked.value.detail)
assert upload.call_args.kwargs["files"]["file"][1] == pdf

View file

@ -3617,6 +3617,33 @@ class TestDeleteTeamModels:
assert any(e[0] == "delete_many" for e in prisma.events)
@pytest.mark.parametrize(
("model_info_patch", "expected_gate"),
[
pytest.param({"tpm": 5}, True, id="unrelated-field"),
pytest.param({"supported_endpoints": ["/v1/embeddings", "/v1/rerank"]}, True, id="endpoint-list-only"),
pytest.param({"enforce_supported_endpoints": False}, False, id="explicit-false"),
],
)
def test_model_info_patch_changes_the_endpoint_gate_only_when_it_sends_the_flag(
model_info_patch: dict[str, object], expected_gate: bool
):
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
result: Final = update_db_model(
db_model=Deployment(
model_name="embedding-only",
litellm_params=LiteLLM_Params(model="openai/text-embedding-3-small"),
model_info=ModelInfo(
id="embedding-only", enforce_supported_endpoints=True, supported_endpoints=["/v1/embeddings"]
),
),
updated_patch=updateDeployment.model_validate({"model_info": model_info_patch}),
)
assert json.loads(result["model_info"]).get("enforce_supported_endpoints") is expected_gate
def _build_db_model_for_blocked_test():
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo

View file

@ -566,6 +566,7 @@ ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
"mongodb",
"nano-gpt",
"neosantara",
"opensearch",
"parasail",
"pinstripes",
"poe",

Some files were not shown because too many files have changed in this diff Show more