mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
commit
3f1d7e2c31
121 changed files with 8818 additions and 249 deletions
9
.github/scripts/smoke_test_native_wheel.py
vendored
9
.github/scripts/smoke_test_native_wheel.py
vendored
|
|
@ -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")
|
||||
|
|
|
|||
21
.github/workflows/cost-map-guard.yml
vendored
21
.github/workflows/cost-map-guard.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
1
litellm-rust/Cargo.lock
generated
1
litellm-rust/Cargo.lock
generated
|
|
@ -4345,6 +4345,7 @@ dependencies = [
|
|||
"litellm-inference-transcription",
|
||||
"litellm-llms",
|
||||
"litellm-llms-types",
|
||||
"litellm-model-catalog",
|
||||
"litellm-router-types",
|
||||
"litellm-secrets",
|
||||
"litellm-secrets-aws",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
litellm-rust/crates/model-catalog/src/bin/check-registry.rs
Normal file
26
litellm-rust/crates/model-catalog/src/bin/check-registry.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
30
litellm-rust/crates/python-bridge/src/model_registry.rs
Normal file
30
litellm-rust/crates/python-bridge/src/model_registry.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
0
litellm/llms/opensearch/__init__.py
Normal file
0
litellm/llms/opensearch/__init__.py
Normal file
0
litellm/llms/opensearch/vector_stores/__init__.py
Normal file
0
litellm/llms/opensearch/vector_stores/__init__.py
Normal file
415
litellm/llms/opensearch/vector_stores/transformation.py
Normal file
415
litellm/llms/opensearch/vector_stores/transformation.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
litellm/llms/soniox/realtime/__init__.py
Normal file
0
litellm/llms/soniox/realtime/__init__.py
Normal file
609
litellm/llms/soniox/realtime/transformation.py
Normal file
609
litellm/llms/soniox/realtime/transformation.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
163
litellm/main.py
163
litellm/main.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
120
litellm/router_utils/endpoint_eligibility.py
Normal file
120
litellm/router_utils/endpoint_eligibility.py
Normal 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="",
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
390
tests/integration/providers/test_pdf_input_support_wire.py
Normal file
390
tests/integration/providers/test_pdf_input_support_wire.py
Normal 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)
|
||||
1515
tests/integration/routing/test_non_chat_model_endpoint_gate.py
Normal file
1515
tests/integration/routing/test_non_chat_model_endpoint_gate.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.*"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
|
|
|||
0
tests/unit/llms/opensearch/__init__.py
Normal file
0
tests/unit/llms/opensearch/__init__.py
Normal file
0
tests/unit/llms/opensearch/vector_stores/__init__.py
Normal file
0
tests/unit/llms/opensearch/vector_stores/__init__.py
Normal 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
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
1
tests/unit/llms/soniox/realtime/__init__.py
Normal file
1
tests/unit/llms/soniox/realtime/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Soniox realtime tests."""
|
||||
|
|
@ -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]
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue